From 5a6aa6ae68e2f0e447259d8f649a800ecbb42666 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Wed, 18 Feb 2026 16:18:00 -0300 Subject: [PATCH 1/9] feat: rewards manager --- .../soulbounds/RewardsFactory.sol | 78 ++ .../soulbounds/RewardsManager.sol | 883 ++++++++++++++++++ .../upgradeables/soulbounds/RewardsServer.sol | 682 ++++++++++++++ scripts/deployRewardsManager.ts | 84 ++ test/rewardsManager.test.ts | 230 +++++ 5 files changed, 1957 insertions(+) create mode 100644 contracts/upgradeables/soulbounds/RewardsFactory.sol create mode 100644 contracts/upgradeables/soulbounds/RewardsManager.sol create mode 100644 contracts/upgradeables/soulbounds/RewardsServer.sol create mode 100644 scripts/deployRewardsManager.ts create mode 100644 test/rewardsManager.test.ts diff --git a/contracts/upgradeables/soulbounds/RewardsFactory.sol b/contracts/upgradeables/soulbounds/RewardsFactory.sol new file mode 100644 index 0000000..65525d2 --- /dev/null +++ b/contracts/upgradeables/soulbounds/RewardsFactory.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// @author Summon.xyz Team - https://summon.xyz + +import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; + +import { RewardsServer } from "./RewardsServer.sol"; + +/** + * @title RewardsFactory + * @notice Deploys per-server RewardsServer, registers with RewardsManager. + */ +interface IRewardsManagerFactory { + function registerServer(bytes32 serverId, address treasury) external; +} + +contract RewardsFactory { + error AddressIsZero(); + error Unauthorized(); + error BeaconNotSet(); + error BeaconsAlreadySet(); + + address public immutable manager; + address public owner; + + UpgradeableBeacon public treasuryBeacon; + + event BeaconsSet(address treasuryBeacon); + event ServerDeployed(bytes32 indexed serverId, address indexed serverAdmin); + + constructor(address _manager) { + if (_manager == address(0)) revert AddressIsZero(); + manager = _manager; + owner = msg.sender; + } + + modifier onlyOwner() { + if (msg.sender != owner) revert Unauthorized(); + _; + } + + function setBeacons(address _treasuryBeacon) external onlyOwner { + if (_treasuryBeacon == address(0)) revert AddressIsZero(); + if (address(treasuryBeacon) != address(0)) revert BeaconsAlreadySet(); + treasuryBeacon = UpgradeableBeacon(_treasuryBeacon); + emit BeaconsSet(_treasuryBeacon); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert AddressIsZero(); + owner = newOwner; + } + + /** + * @dev Deploy a new server: RewardsServer proxy. Register with manager. Caller = server admin. + */ + function deployServer(bytes32 serverId) external { + if (address(treasuryBeacon) == address(0)) revert BeaconNotSet(); + + address serverAdmin = msg.sender; + + bytes memory treasuryInitData = abi.encodeWithSelector( + RewardsServer.initialize.selector, + manager, + manager, + serverAdmin + ); + address treasuryProxy = address( + new BeaconProxy(address(treasuryBeacon), treasuryInitData) + ); + + IRewardsManagerFactory(manager).registerServer(serverId, treasuryProxy); + + emit ServerDeployed(serverId, serverAdmin); + } +} diff --git a/contracts/upgradeables/soulbounds/RewardsManager.sol b/contracts/upgradeables/soulbounds/RewardsManager.sol new file mode 100644 index 0000000..523e792 --- /dev/null +++ b/contracts/upgradeables/soulbounds/RewardsManager.sol @@ -0,0 +1,883 @@ +// 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 { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +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 { + MessageHashUtils +} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +import { LibItems } from "../../libraries/LibItems.sol"; +import { RewardsServer } from "./RewardsServer.sol"; + +/** + * @title RewardsManager + * @notice Multitenant rewards manager: per-tenant RewardsServer; permissionless claim via server signature. + * No AccessToken: users claim rewards directly with a signed message. + */ +contract RewardsManager is + Initializable, + AccessControlUpgradeable, + PausableUpgradeable, + ReentrancyGuardUpgradeable, + UUPSUpgradeable +{ + using SafeERC20 for IERC20; + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + error AddressIsZero(); + error ServerAlreadyExists(); + error ServerDoesNotExist(); + error InvalidServerId(); + error BeaconNotInitialized(); + error BeaconsAlreadyInitialized(); + error InvalidSignature(); + error AlreadyUsedSignature(); + error UnauthorizedServerAdmin(); + error InsufficientBalance(); + error InvalidAmount(); + error InvalidInput(); + error NonceAlreadyUsed(); + error TokenNotExist(); + error ExceedMaxSupply(); + error MintPaused(); + error ClaimRewardPaused(); + error DupTokenId(); + error TokenNotWhitelisted(); + error InsufficientTreasuryBalance(); + error TransferFailed(); + error InvalidLength(); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant DEV_CONFIG_ROLE = keccak256("DEV_CONFIG_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant FACTORY_ROLE = keccak256("FACTORY_ROLE"); + bytes32 private constant REWARDS_MANAGER_ROLE = + keccak256("REWARDS_MANAGER_ROLE"); + + /*////////////////////////////////////////////////////////////// + STRUCTS + //////////////////////////////////////////////////////////////*/ + + struct Server { + address treasury; + bool exists; + } + + /*////////////////////////////////////////////////////////////// + STATE + //////////////////////////////////////////////////////////////*/ + + // Per-server registry (one manager has many servers) + mapping(bytes32 => Server) private servers; + + // Beacons (single implementation per type, upgradeable for all servers) + UpgradeableBeacon public treasuryBeacon; + + // Global signature replay protection + mapping(bytes => bool) private usedSignatures; + + uint256[45] private __gap; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event ServerDeployed(bytes32 indexed serverId, address treasury); + + event ServerSignerUpdated( + bytes32 indexed serverId, + address indexed signer, + bool isActive + ); + + event ServerWithdrawerUpdated( + bytes32 indexed serverId, + address indexed account, + bool isActive + ); + + event ServerAdminTransferred( + bytes32 indexed serverId, + address indexed oldAdmin, + address indexed newAdmin + ); + + event TokenAdded(bytes32 indexed serverId, uint256 indexed tokenId); + event Minted( + bytes32 indexed serverId, + address indexed to, + uint256 indexed tokenId, + uint256 amount, + bool soulbound + ); + event Claimed( + bytes32 indexed serverId, + address indexed to, + uint256 indexed tokenId, + uint256 amount + ); + event TokenMintPausedUpdated( + bytes32 indexed serverId, + uint256 indexed tokenId, + bool isPaused + ); + event ClaimRewardPausedUpdated( + bytes32 indexed serverId, + uint256 indexed tokenId, + bool isPaused + ); + event RewardSupplyChanged( + bytes32 indexed serverId, + uint256 indexed tokenId, + uint256 oldSupply, + uint256 newSupply + ); + event TokenURIChanged( + bytes32 indexed serverId, + uint256 indexed tokenId, + string newUri + ); + event AssetsWithdrawn( + bytes32 indexed serverId, + LibItems.RewardType rewardType, + address indexed to, + uint256 amount + ); + + /*////////////////////////////////////////////////////////////// + INITIALIZER + //////////////////////////////////////////////////////////////*/ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize( + address _devWallet, + address _managerWallet + ) external initializer { + if ( + _devWallet == address(0) || + _managerWallet == 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, _managerWallet); + _grantRole(MINTER_ROLE, _managerWallet); + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyRole(UPGRADER_ROLE) {} + + /*////////////////////////////////////////////////////////////// + BEACON CONFIGURATION + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Initialize beacon for RewardsServer implementation. Can only be called once by DEV_CONFIG_ROLE. + */ + function initializeBeacons(address _treasuryImplementation) external onlyRole(DEV_CONFIG_ROLE) { + if (address(_treasuryImplementation) == address(0)) { + revert AddressIsZero(); + } + if (address(treasuryBeacon) != address(0)) { + revert BeaconsAlreadyInitialized(); + } + + treasuryBeacon = new UpgradeableBeacon( + _treasuryImplementation, + address(this) + ); + } + + /*////////////////////////////////////////////////////////////// + SERVER MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + function _getServer( + bytes32 serverId + ) internal view returns (Server storage s) { + s = servers[serverId]; + if (!s.exists) { + revert ServerDoesNotExist(); + } + } + + /** + * @dev Register a server deployed by RewardsFactory. Manager already has REWARDS_MANAGER_ROLE from server.initialize(..., manager, ...). + */ + function registerServer(bytes32 serverId, address treasury) external onlyRole(FACTORY_ROLE) { + if (serverId == bytes32(0)) revert InvalidServerId(); + if (servers[serverId].exists) revert ServerAlreadyExists(); + if (treasury == address(0)) revert AddressIsZero(); + + servers[serverId] = Server({ treasury: treasury, exists: true }); + + emit ServerDeployed(serverId, treasury); + } + + /** + * @dev Transfer server admin to a new address. Permission checked by RewardsServer. + */ + function transferServerAdmin(bytes32 serverId, address newAdmin) external { + if (newAdmin == address(0)) revert AddressIsZero(); + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).transferServerAdminAllowedBy(msg.sender, newAdmin); + emit ServerAdminTransferred(serverId, msg.sender, newAdmin); + } + + /** + * @dev View function to get server treasury (and admin via serverId). + */ + function getServer(bytes32 serverId) external view returns (address treasury) { + Server storage s = _getServer(serverId); + return s.treasury; + } + + /*////////////////////////////////////////////////////////////// + PER-SERVER ACCESS CONTROL + //////////////////////////////////////////////////////////////*/ + + function setServerSigner( + bytes32 serverId, + address signer, + bool isActive + ) external { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).setSignerAllowedBy(msg.sender, signer, isActive); + emit ServerSignerUpdated(serverId, signer, isActive); + } + + function isServerSigner(bytes32 serverId, address signer) external view returns (bool) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).isSigner(signer); + } + + function setServerWithdrawer( + bytes32 serverId, + address account, + bool isActive + ) external { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).setWithdrawerAllowedBy(msg.sender, account, isActive); + emit ServerWithdrawerUpdated(serverId, account, isActive); + } + + function isServerWithdrawer(bytes32 serverId, address account) external view returns (bool) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).isWithdrawer(account); + } + + /*////////////////////////////////////////////////////////////// + SIGNATURE VERIFICATION + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Internal helper to verify a tenant-scoped signature. + * + * Message format (hashed then wrapped in EIP-191 prefix): + * keccak256(abi.encodePacked(contractAddress, chainId, serverId, beneficiary, expiration, tokenIds, nonce)) + */ + function _verifyServerSignature( + bytes32 serverId, + address beneficiary, + uint256 expiration, + uint256[] memory tokenIds, + uint256 nonce, + bytes calldata signature + ) internal view returns (address) { + if (usedSignatures[signature]) { + revert AlreadyUsedSignature(); + } + + uint256 currentChainId = block.chainid; + bytes32 message = keccak256( + abi.encode( + address(this), + currentChainId, + serverId, + beneficiary, + expiration, + tokenIds, + nonce + ) + ); + bytes32 hash = MessageHashUtils.toEthSignedMessageHash(message); + address signer = ECDSA.recover(hash, signature); + + Server storage s = _getServer(serverId); + if (!RewardsServer(s.treasury).isSigner(signer)) { + revert InvalidSignature(); + } + + if (block.timestamp >= expiration) { + revert InvalidSignature(); + } + + return signer; + } + + /*////////////////////////////////////////////////////////////// + PAUSE + //////////////////////////////////////////////////////////////*/ + + function pause() external onlyRole(MANAGER_ROLE) { + _pause(); + } + + function unpause() external onlyRole(MANAGER_ROLE) { + _unpause(); + } + + /*////////////////////////////////////////////////////////////// + TREASURY MANAGEMENT (PER TENANT) + //////////////////////////////////////////////////////////////*/ + + function whitelistToken( + bytes32 serverId, + address token, + LibItems.RewardType rewardType + ) external onlyRole(MANAGER_ROLE) { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).whitelistToken(token, rewardType); + } + + function removeTokenFromWhitelist( + bytes32 serverId, + address token + ) external onlyRole(MANAGER_ROLE) { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).removeTokenFromWhitelist(token); + } + + function depositToTreasury( + bytes32 serverId, + address token, + uint256 amount + ) external nonReentrant { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).depositToTreasury(token, amount, msg.sender); + } + + /*////////////////////////////////////////////////////////////// + REWARD TOKEN CREATION AND SUPPLY (PER TENANT) + //////////////////////////////////////////////////////////////*/ + + function createTokenAndDepositRewards( + bytes32 serverId, + LibItems.RewardToken calldata token + ) external payable onlyRole(MANAGER_ROLE) nonReentrant { + uint256 ethRequired = _calculateETHRequiredForToken(token); + if (msg.value < ethRequired) revert InsufficientBalance(); + Server storage s = _getServer(serverId); + _validateAndCreateTokenAndDepositRewards(s.treasury, token); + emit TokenAdded(serverId, token.tokenId); + } + + function updateTokenMintPaused( + bytes32 serverId, + uint256 tokenId, + bool isPaused + ) external onlyRole(MANAGER_ROLE) { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).setTokenMintPaused(tokenId, isPaused); + emit TokenMintPausedUpdated(serverId, tokenId, isPaused); + } + + function updateClaimRewardPaused( + bytes32 serverId, + uint256 tokenId, + bool isPaused + ) external onlyRole(MANAGER_ROLE) { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).setClaimRewardPaused(tokenId, isPaused); + emit ClaimRewardPausedUpdated(serverId, tokenId, isPaused); + } + + function increaseRewardSupply( + bytes32 serverId, + uint256 tokenId, + uint256 additionalSupply + ) external onlyRole(MANAGER_ROLE) { + Server storage s = _getServer(serverId); + RewardsServer serverContract = RewardsServer(s.treasury); + if (!serverContract.isTokenExists(tokenId)) revert TokenNotExist(); + uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; + serverContract.increaseRewardSupply(tokenId, additionalSupply); + emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply + additionalSupply); + } + + function updateTokenUri( + bytes32 serverId, + uint256 tokenId, + string calldata newUri + ) external onlyRole(MANAGER_ROLE) { + Server storage s = _getServer(serverId); + _updateTokenUri(s.treasury, tokenId, newUri); + emit TokenURIChanged(serverId, tokenId, newUri); + } + + function _transferEther(address payable to, uint256 amount) private { + if (address(this).balance < amount) revert InsufficientBalance(); + (bool ok, ) = to.call{ value: amount }(""); + if (!ok) revert TransferFailed(); + } + + function _calculateETHRequiredForToken( + LibItems.RewardToken calldata token + ) internal pure returns (uint256) { + uint256 total; + for (uint256 i = 0; i < token.rewards.length; i++) { + if (token.rewards[i].rewardType == LibItems.RewardType.ETHER) { + total += token.rewards[i].rewardAmount; + } + } + return total * token.maxSupply; + } + + function _validateAndCreateTokenAndDepositRewards( + address treasuryAddr, + LibItems.RewardToken calldata token + ) internal { + RewardsServer server = RewardsServer(treasuryAddr); + if (token.maxSupply == 0) revert InvalidAmount(); + if ( + bytes(token.tokenUri).length == 0 || + token.rewards.length == 0 || + token.tokenId == 0 + ) revert InvalidInput(); + if (server.isTokenExists(token.tokenId)) revert DupTokenId(); + + 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.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(); + } + + for (uint256 i = 0; i < token.rewards.length; i++) { + LibItems.Reward memory r = token.rewards[i]; + if (r.rewardType == LibItems.RewardType.ERC20) { + if (!server.whitelistedTokens(r.rewardTokenAddress)) revert TokenNotWhitelisted(); + uint256 totalAmount = r.rewardAmount * token.maxSupply; + uint256 balance = IERC20(r.rewardTokenAddress).balanceOf(treasuryAddr); + uint256 reserved = server.reservedAmounts(r.rewardTokenAddress); + if (balance < reserved + totalAmount) revert InsufficientTreasuryBalance(); + server.increaseERC20Reserved(r.rewardTokenAddress, totalAmount); + } else if (r.rewardType == LibItems.RewardType.ERC721) { + if (!server.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) != treasuryAddr || server.isErc721Reserved(r.rewardTokenAddress, tid)) { + revert InsufficientTreasuryBalance(); + } + } + for (uint256 j = 0; j < r.rewardTokenIds.length; j++) { + server.reserveERC721(r.rewardTokenAddress, r.rewardTokenIds[j]); + } + } else if (r.rewardType == LibItems.RewardType.ERC1155) { + if (!server.whitelistedTokens(r.rewardTokenAddress)) revert TokenNotWhitelisted(); + uint256 totalAmount = r.rewardAmount * token.maxSupply; + uint256 balance = IERC1155(r.rewardTokenAddress).balanceOf(treasuryAddr, r.rewardTokenId); + uint256 reserved = server.erc1155ReservedAmounts(r.rewardTokenAddress, r.rewardTokenId); + if (balance < reserved + totalAmount) revert InsufficientTreasuryBalance(); + server.increaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, totalAmount); + } + } + + server.addRewardToken(token.tokenId, token); + } + + function _updateTokenUri( + address treasuryAddr, + uint256 tokenId, + string calldata newUri + ) internal { + RewardsServer server = RewardsServer(treasuryAddr); + if (!server.isTokenExists(tokenId)) revert TokenNotExist(); + LibItems.RewardToken memory rt = server.getRewardToken(tokenId); + rt.tokenUri = newUri; + server.updateRewardToken(tokenId, rt); + } + + function _distributeReward( + address treasuryAddr, + address to, + uint256 rewardTokenId + ) internal { + RewardsServer serverContract = RewardsServer(treasuryAddr); + LibItems.RewardToken memory rewardToken = serverContract.getRewardToken(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) { + _transferEther(payable(to), r.rewardAmount); + } else if (r.rewardType == LibItems.RewardType.ERC20) { + serverContract.distributeERC20(r.rewardTokenAddress, to, r.rewardAmount); + serverContract.decreaseERC20Reserved(r.rewardTokenAddress, r.rewardAmount); + } else if (r.rewardType == LibItems.RewardType.ERC721) { + uint256 currentIndex = serverContract.getERC721RewardCurrentIndex(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]; + serverContract.releaseERC721(r.rewardTokenAddress, nftId); + serverContract.distributeERC721(r.rewardTokenAddress, to, nftId); + } + serverContract.incrementERC721RewardIndex(rewardTokenId, i); + } else if (r.rewardType == LibItems.RewardType.ERC1155) { + serverContract.decreaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, r.rewardAmount); + serverContract.distributeERC1155(r.rewardTokenAddress, to, r.rewardTokenId, r.rewardAmount); + } + } + } + + function _claimRewards( + address treasuryAddr, + address to, + uint256 tokenId, + uint256 amount + ) internal { + RewardsServer server = RewardsServer(treasuryAddr); + if (to == address(0)) revert AddressIsZero(); + if (!server.isTokenExists(tokenId)) revert TokenNotExist(); + if (server.isClaimRewardPaused(tokenId)) revert ClaimRewardPaused(); + if (amount == 0) revert InvalidAmount(); + + uint256 newSupply = server.currentRewardSupply(tokenId) + amount; + if (newSupply > server.getRewardToken(tokenId).maxSupply) revert ExceedMaxSupply(); + server.increaseCurrentSupply(tokenId, amount); + + for (uint256 i = 0; i < amount; i++) { + _distributeReward(treasuryAddr, to, tokenId); + } + } + + function _decodeClaimData( + bytes calldata data + ) private pure returns (address contractAddress, uint256 chainId, address beneficiary, uint256 expiration, uint256[] memory tokenIds) { + (contractAddress, chainId, beneficiary, expiration, tokenIds) = + abi.decode(data, (address, uint256, address, uint256, uint256[])); + } + + /** + * @dev Permissionless claim: beneficiary presents server signature and receives rewards for each tokenId. + * Caller must be the beneficiary. No AccessToken; rewards are distributed directly from treasury. + */ + function claim( + bytes32 serverId, + bytes calldata data, + uint256 nonce, + bytes calldata signature + ) external nonReentrant whenNotPaused { + ( + address contractAddress, + uint256 chainId, + address beneficiary, + uint256 expiration, + uint256[] memory tokenIds + ) = _decodeClaimData(data); + + if (contractAddress != address(this) || chainId != block.chainid) revert InvalidInput(); + if (block.timestamp >= expiration) revert InvalidSignature(); + if (msg.sender != beneficiary) revert InvalidInput(); + + _verifyServerSignature(serverId, beneficiary, expiration, tokenIds, nonce, signature); + usedSignatures[signature] = true; + + Server storage s = _getServer(serverId); + RewardsServer serverContract = RewardsServer(s.treasury); + if (serverContract.userNonces(beneficiary, nonce)) revert NonceAlreadyUsed(); + serverContract.setUserNonce(beneficiary, nonce, true); + + for (uint256 i = 0; i < tokenIds.length; i++) { + _claimRewards(s.treasury, beneficiary, tokenIds[i], 1); + emit Claimed(serverId, beneficiary, tokenIds[i], 1); + } + } + + function withdrawAssets( + bytes32 serverId, + LibItems.RewardType rewardType, + address to, + address tokenAddress, + uint256[] calldata tokenIds, + uint256[] calldata amounts + ) external onlyRole(MANAGER_ROLE) { + if (to == address(0)) revert AddressIsZero(); + Server storage s = _getServer(serverId); + RewardsServer serverContract = RewardsServer(s.treasury); + + if (rewardType == LibItems.RewardType.ETHER) { + _transferEther(payable(to), amounts[0]); + } else if (rewardType == LibItems.RewardType.ERC20) { + serverContract.withdrawUnreservedTreasury(tokenAddress, to); + } else if (rewardType == LibItems.RewardType.ERC721) { + for (uint256 i = 0; i < tokenIds.length; i++) { + serverContract.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++) { + serverContract.withdrawERC1155UnreservedTreasury(tokenAddress, to, tokenIds[i], amounts[i]); + } + } + emit AssetsWithdrawn(serverId, rewardType, to, amounts.length > 0 ? amounts[0] : 0); + } + + /*////////////////////////////////////////////////////////////// + TREASURY WITHDRAW (PER TENANT) + //////////////////////////////////////////////////////////////*/ + + function withdrawUnreservedTreasury( + bytes32 serverId, + address token, + address to + ) external nonReentrant { + Server storage s = _getServer(serverId); + if (!RewardsServer(s.treasury).isWithdrawer(msg.sender)) revert UnauthorizedServerAdmin(); + RewardsServer(s.treasury).withdrawUnreservedTreasury(token, to); + } + + function withdrawERC721UnreservedTreasury( + bytes32 serverId, + address token, + address to, + uint256 tokenId + ) external nonReentrant { + Server storage s = _getServer(serverId); + if (!RewardsServer(s.treasury).isWithdrawer(msg.sender)) revert UnauthorizedServerAdmin(); + RewardsServer(s.treasury).withdrawERC721UnreservedTreasury(token, to, tokenId); + } + + function withdrawERC1155UnreservedTreasury( + bytes32 serverId, + address token, + address to, + uint256 tokenId, + uint256 amount + ) external nonReentrant { + Server storage s = _getServer(serverId); + if (!RewardsServer(s.treasury).isWithdrawer(msg.sender)) revert UnauthorizedServerAdmin(); + RewardsServer(s.treasury).withdrawERC1155UnreservedTreasury(token, to, tokenId, amount); + } + + /*////////////////////////////////////////////////////////////// + VIEW HELPERS (PER TENANT) + //////////////////////////////////////////////////////////////*/ + + function getServerTreasuryBalances( + bytes32 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 + ) + { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).getAllTreasuryBalances(s.treasury); + } + + function getServerAllItemIds(bytes32 serverId) external view returns (uint256[] memory) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).getAllItemIds(); + } + + function getServerTokenRewards( + bytes32 serverId, + uint256 tokenId + ) external view returns (LibItems.Reward[] memory) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).getTokenRewards(tokenId); + } + + function getServerTreasuryBalance( + bytes32 serverId, + address token + ) external view returns (uint256) { + Server storage s = _getServer(serverId); + return + RewardsServer(s.treasury).getTreasuryBalance( + s.treasury, + token + ); + } + + function getServerReservedAmount( + bytes32 serverId, + address token + ) external view returns (uint256) { + Server storage s = _getServer(serverId); + return + RewardsServer(s.treasury).getReservedAmount( + s.treasury, + token + ); + } + + function getServerAvailableTreasuryBalance( + bytes32 serverId, + address token + ) external view returns (uint256) { + Server storage s = _getServer(serverId); + return + RewardsServer(s.treasury).getAvailableTreasuryBalance( + s.treasury, + token + ); + } + + function getServerWhitelistedTokens( + bytes32 serverId + ) external view returns (address[] memory) { + Server storage s = _getServer(serverId); + return + RewardsServer(s.treasury).getWhitelistedTokens( + s.treasury + ); + } + + function isServerWhitelistedToken( + bytes32 serverId, + address token + ) external view returns (bool) { + Server storage s = _getServer(serverId); + return + RewardsServer(s.treasury).isWhitelistedToken( + s.treasury, + token + ); + } + + function isTokenExist(bytes32 serverId, uint256 tokenId) public view returns (bool) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).isTokenExists(tokenId); + } + + function getTokenDetails( + bytes32 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 + ) + { + Server storage s = _getServer(serverId); + LibItems.RewardToken memory rt = RewardsServer(s.treasury).getRewardToken(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; + } + } + + /// @dev True if token exists, claim is not paused, and there is remaining supply to claim. + function canUserClaim( + bytes32 serverId, + address, + uint256 tokenId + ) external view returns (bool) { + Server storage s = _getServer(serverId); + RewardsServer serverContract = RewardsServer(s.treasury); + if (!serverContract.isTokenExists(tokenId)) return false; + if (serverContract.isClaimRewardPaused(tokenId)) return false; + uint256 maxSupply = serverContract.getRewardToken(tokenId).maxSupply; + uint256 current = serverContract.currentRewardSupply(tokenId); + return current < maxSupply; + } + + function getRemainingSupply( + bytes32 serverId, + uint256 tokenId + ) external view returns (uint256) { + Server storage s = _getServer(serverId); + RewardsServer serverContract = RewardsServer(s.treasury); + if (!serverContract.isTokenExists(tokenId)) return 0; + uint256 maxSupply = serverContract.getRewardToken(tokenId).maxSupply; + uint256 current = serverContract.currentRewardSupply(tokenId); + if (current >= maxSupply) return 0; + return maxSupply - current; + } + + function isNonceUsed( + bytes32 serverId, + address user, + uint256 nonce + ) external view returns (bool) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).userNonces(user, nonce); + } + + receive() external payable {} +} + diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol new file mode 100644 index 0000000..d12ebfa --- /dev/null +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -0,0 +1,682 @@ +// 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 { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.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"; + +interface IRewards { + function getAllItemIds() external view returns (uint256[] memory); + function getTokenRewards(uint256 tokenId) external view returns (LibItems.Reward[] memory); +} + +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. + * This contract is upgradeable using the UUPS pattern. + */ +contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeable, ERC721HolderUpgradeable, ERC1155HolderUpgradeable { + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + error AddressIsZero(); + error InvalidAmount(); + error TokenNotWhitelisted(); + error TokenAlreadyWhitelisted(); + error RewardTokenAlreadyExists(); + error TokenNotExist(); + error InsufficientTreasuryBalance(); + error TokenHasReserves(); + error InsufficientBalance(); + error UnauthorizedServerAdmin(); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant SERVER_ADMIN_ROLE = keccak256("SERVER_ADMIN_ROLE"); + + /*////////////////////////////////////////////////////////////// + STATE VARIABLES + //////////////////////////////////////////////////////////////*/ + + // Server-level access (admin, signers for claim, withdrawers) + mapping(address => bool) public signers; + mapping(address => bool) public withdrawers; + + // 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 => bool) public isTokenMintPaused; + mapping(uint256 => bool) public isClaimRewardPaused; + 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 userNonces; + + uint256[33] private __gap; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + event TreasuryDeposit(address indexed token, uint256 amount); + event SignerUpdated(address indexed account, bool active); + event WithdrawerUpdated(address indexed account, bool active); + event ServerAdminTransferred(address indexed oldAdmin, address indexed newAdmin); + + /*////////////////////////////////////////////////////////////// + INITIALIZER + //////////////////////////////////////////////////////////////*/ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address _admin, address _rewardsContract, address _serverAdmin) external initializer { + if (_admin == address(0) || _rewardsContract == address(0) || _serverAdmin == address(0)) { + revert AddressIsZero(); + } + + __AccessControl_init(); + __ERC721Holder_init(); + __ERC1155Holder_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(UPGRADER_ROLE, _admin); + _grantRole(REWARDS_MANAGER_ROLE, _rewardsContract); + _grantRole(SERVER_ADMIN_ROLE, _serverAdmin); + } + + function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADER_ROLE) {} + + /*////////////////////////////////////////////////////////////// + SERVER ACCESS CONTROL (admin, signers, withdrawers) + //////////////////////////////////////////////////////////////*/ + + function setSigner(address account, bool active) external onlyRole(SERVER_ADMIN_ROLE) { + if (account == address(0)) revert AddressIsZero(); + signers[account] = active; + emit SignerUpdated(account, active); + } + + function setWithdrawer(address account, bool active) external onlyRole(SERVER_ADMIN_ROLE) { + if (account == address(0)) revert AddressIsZero(); + withdrawers[account] = active; + emit WithdrawerUpdated(account, active); + } + + function transferServerAdmin(address newAdmin) external onlyRole(SERVER_ADMIN_ROLE) { + if (newAdmin == address(0)) revert AddressIsZero(); + address oldAdmin = msg.sender; + _revokeRole(SERVER_ADMIN_ROLE, oldAdmin); + _grantRole(SERVER_ADMIN_ROLE, newAdmin); + emit ServerAdminTransferred(oldAdmin, newAdmin); + } + + function isSigner(address account) external view returns (bool) { + return signers[account]; + } + + function isWithdrawer(address account) external view returns (bool) { + return withdrawers[account]; + } + + function setSignerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { + if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); + if (account == address(0)) revert AddressIsZero(); + signers[account] = active; + emit SignerUpdated(account, active); + } + + function setWithdrawerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { + if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); + if (account == address(0)) revert AddressIsZero(); + withdrawers[account] = active; + emit WithdrawerUpdated(account, active); + } + + function transferServerAdminAllowedBy(address caller, address newAdmin) external onlyRole(REWARDS_MANAGER_ROLE) { + if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); + if (newAdmin == address(0)) revert AddressIsZero(); + _revokeRole(SERVER_ADMIN_ROLE, caller); + _grantRole(SERVER_ADMIN_ROLE, newAdmin); + emit ServerAdminTransferred(caller, newAdmin); + } + + /*////////////////////////////////////////////////////////////// + TREASURY MANAGEMENT FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + function whitelistToken(address _token, LibItems.RewardType _type) external onlyRole(REWARDS_MANAGER_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; + } + } + + function removeTokenFromWhitelist(address _token) external onlyRole(REWARDS_MANAGER_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; + for (uint256 i = 0; i < whitelistedTokenList.length; i++) { + if (whitelistedTokenList[i] == _token) { + whitelistedTokenList[i] = whitelistedTokenList[whitelistedTokenList.length - 1]; + whitelistedTokenList.pop(); + break; + } + } + } + + function depositToTreasury(address _token, uint256 _amount, address _from) external onlyRole(REWARDS_MANAGER_ROLE) { + if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); + if (_amount == 0) revert InvalidAmount(); + + SafeERC20.safeTransferFrom(IERC20(_token), _from, address(this), _amount); + emit TreasuryDeposit(_token, _amount); + } + + function withdrawUnreservedTreasury(address _token, address _to) external onlyRole(REWARDS_MANAGER_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); + } + + function withdrawERC721UnreservedTreasury(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_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); + } + + function withdrawERC1155UnreservedTreasury(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_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, ""); + } + + /*////////////////////////////////////////////////////////////// + DISTRIBUTION FUNCTIONS (for claims) + //////////////////////////////////////////////////////////////*/ + + function distributeERC20(address _token, address _to, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + SafeERC20.safeTransfer(IERC20(_token), _to, _amount); + } + + function distributeERC721(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { + IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); + } + + function distributeERC1155(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + IERC1155(_token).safeTransferFrom(address(this), _to, _tokenId, _amount, ""); + } + + /*////////////////////////////////////////////////////////////// + TREASURY VIEW FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + function getAllTreasuryBalances(address rewardsContract) + 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(rewardsContract); + 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(rewardsContract, tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); + tokenIds[currentIndex] = 0; + currentIndex++; + } else if (tokenType == LibItems.RewardType.ERC721) { + _processERC721Token(rewardsContract, tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); + tokenIds[currentIndex] = 0; + currentIndex++; + } + } + + currentIndex = _processERC1155Tokens(rewardsContract, erc1155Count, currentIndex, addresses, totalBalances, reservedBalances, availableBalances, symbols, names, types, tokenIds); + + return (addresses, totalBalances, reservedBalances, availableBalances, symbols, names, types, tokenIds); + } + + function getTreasuryBalance(address, address _token) external view returns (uint256) { + return IERC20(_token).balanceOf(address(this)); + } + + function getReservedAmount(address, address _token) external view returns (uint256) { + return reservedAmounts[_token]; + } + + function getAvailableTreasuryBalance(address, address _token) external view returns (uint256) { + uint256 balance = IERC20(_token).balanceOf(address(this)); + uint256 reserved = reservedAmounts[_token]; + return balance > reserved ? balance - reserved : 0; + } + + function getWhitelistedTokens(address) external view returns (address[] memory) { + return whitelistedTokenList; + } + + function isWhitelistedToken(address, address _token) external view returns (bool) { + return whitelistedTokens[_token]; + } + + /*////////////////////////////////////////////////////////////// + REWARD RESERVE & VIEW (for IRewards) + //////////////////////////////////////////////////////////////*/ + + function getAllItemIds() external view returns (uint256[] memory) { + return itemIds; + } + + function getTokenRewards(uint256 _tokenId) external view returns (LibItems.Reward[] memory) { + return tokenRewards[_tokenId].rewards; + } + + function getRewardToken(uint256 _tokenId) external view returns (LibItems.RewardToken memory) { + return tokenRewards[_tokenId]; + } + + function isTokenExists(uint256 _tokenId) external view returns (bool) { + return tokenExists[_tokenId]; + } + + function getERC721RewardCurrentIndex(uint256 _rewardTokenId, uint256 _rewardIndex) external view returns (uint256) { + return erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex]; + } + + /*////////////////////////////////////////////////////////////// + RESERVATION & REWARD MUTATORS (REWARDS_MANAGER_ROLE) + //////////////////////////////////////////////////////////////*/ + + function increaseERC20Reserved(address _token, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + reservedAmounts[_token] += _amount; + } + + function decreaseERC20Reserved(address _token, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + if (reservedAmounts[_token] >= _amount) { + reservedAmounts[_token] -= _amount; + } + } + + function reserveERC721(address _token, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { + isErc721Reserved[_token][_tokenId] = true; + erc721TotalReserved[_token]++; + } + + function releaseERC721(address _token, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { + isErc721Reserved[_token][_tokenId] = false; + if (erc721TotalReserved[_token] > 0) { + erc721TotalReserved[_token]--; + } + } + + function increaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + erc1155ReservedAmounts[_token][_tokenId] += _amount; + erc1155TotalReserved[_token] += _amount; + } + + function decreaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + if (erc1155ReservedAmounts[_token][_tokenId] >= _amount) { + erc1155ReservedAmounts[_token][_tokenId] -= _amount; + } + if (erc1155TotalReserved[_token] >= _amount) { + erc1155TotalReserved[_token] -= _amount; + } + } + + function addRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) external onlyRole(REWARDS_MANAGER_ROLE) { + if (tokenExists[_tokenId]) revert RewardTokenAlreadyExists(); + + tokenExists[_tokenId] = true; + tokenRewards[_tokenId] = _rewardToken; + itemIds.push(_tokenId); + currentRewardSupply[_tokenId] = 0; + } + + function updateRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) external onlyRole(REWARDS_MANAGER_ROLE) { + if (!tokenExists[_tokenId]) revert TokenNotWhitelisted(); + tokenRewards[_tokenId] = _rewardToken; + } + + /** + * @dev Increase max supply for a reward token; reserves additional ERC20/ERC1155 on this server. + */ + function increaseRewardSupply(uint256 _tokenId, uint256 _additionalSupply) external onlyRole(REWARDS_MANAGER_ROLE) { + if (!tokenExists[_tokenId]) revert TokenNotExist(); + if (_additionalSupply == 0) revert InvalidAmount(); + + 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.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; + } + + function setTokenMintPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_MANAGER_ROLE) { + isTokenMintPaused[_tokenId] = _isPaused; + } + + function setClaimRewardPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_MANAGER_ROLE) { + isClaimRewardPaused[_tokenId] = _isPaused; + } + + function increaseCurrentSupply(uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + currentRewardSupply[_tokenId] += _amount; + } + + function decreaseCurrentSupply(uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + if (currentRewardSupply[_tokenId] >= _amount) { + currentRewardSupply[_tokenId] -= _amount; + } + } + + function setUserNonce(address _user, uint256 _nonce, bool _used) external onlyRole(REWARDS_MANAGER_ROLE) { + userNonces[_user][_nonce] = _used; + } + + function incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex) external onlyRole(REWARDS_MANAGER_ROLE) { + erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex]++; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HELPER FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + function _processERC20Token( + address, + 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, + 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( + address rewardsContract, + 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) { + IRewards rewards = IRewards(rewardsContract); + uint256[] memory ids = rewards.getAllItemIds(); + + 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 = rewards.getTokenRewards(ids[i]); + + 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(address rewardsContract) private view returns (uint256) { + IRewards rewards = IRewards(rewardsContract); + uint256[] memory ids = rewards.getAllItemIds(); + + address[] memory uniqueAddresses = new address[](ids.length * 10); + uint256[] memory uniqueTokenIds = new uint256[](ids.length * 10); + uint256 count = 0; + + for (uint256 i = 0; i < ids.length; i++) { + LibItems.Reward[] memory rewardsList = rewards.getTokenRewards(ids[i]); + + 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; + } + + function supportsInterface(bytes4 interfaceId) public view virtual override(AccessControlUpgradeable, ERC1155HolderUpgradeable) returns (bool) { + return super.supportsInterface(interfaceId); + } +} diff --git a/scripts/deployRewardsManager.ts b/scripts/deployRewardsManager.ts new file mode 100644 index 0000000..30d0784 --- /dev/null +++ b/scripts/deployRewardsManager.ts @@ -0,0 +1,84 @@ +import { ethers, upgrades } from 'hardhat'; + +/** + * Deploy RewardsManager (one manager, many servers), beacon and RewardsFactory. + * + * 1. Deploy RewardsServer implementation (for beacon) + * 2. Deploy RewardsManager (UUPS proxy), initialize with (dev, manager) + * 3. initializeBeacons(rewardsServerImpl) + * 4. Deploy RewardsFactory(manager), setBeacons, grant FACTORY_ROLE + * + * After this, anyone can call factory.deployServer(serverId) 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('RewardsManager Deployment'); + console.log('========================================'); + console.log('Deployer:', deployer.address); + console.log('========================================\n'); + + const devWallet = deployer.address; + const managerWallet = deployer.address; + + // 1. Deploy RewardsServer implementation (no proxy - used as beacon implementation) + 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 RewardsManager (UUPS proxy) + console.log('\n2. Deploying RewardsManager (UUPS proxy)...'); + const RewardsManager = await ethers.getContractFactory('RewardsManager'); + const manager = await upgrades.deployProxy( + RewardsManager, + [devWallet, managerWallet], + { kind: 'uups', initializer: 'initialize' } + ); + await manager.waitForDeployment(); + const managerAddress = await manager.getAddress(); + const managerImpl = await upgrades.erc1967.getImplementationAddress(managerAddress); + console.log(' RewardsManager proxy:', managerAddress); + console.log(' RewardsManager impl:', managerImpl); + + // 3. Initialize beacon + console.log('\n3. Initializing rewards server beacon...'); + let tx = await manager.initializeBeacons(rewardsServerImplAddress); + await tx.wait(); + console.log(' Beacon initialized.'); + + // 4. Deploy RewardsFactory and wire to manager + console.log('\n4. Deploying RewardsFactory...'); + const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); + const factory = await RewardsFactory.deploy(managerAddress); + await factory.waitForDeployment(); + const factoryAddress = await factory.getAddress(); + tx = await factory.setBeacons(await manager.treasuryBeacon()); + await tx.wait(); + tx = await manager.grantRole(await manager.FACTORY_ROLE(), factoryAddress); + await tx.wait(); + console.log(' RewardsFactory:', factoryAddress); + + console.log('\n========================================'); + console.log('Deployment complete.'); + console.log('========================================'); + console.log('RewardsManager:', managerAddress); + console.log('RewardsFactory:', factoryAddress); + console.log('To create a server: factory.deployServer(serverId)'); + console.log(' e.g. serverId = ethers.keccak256(ethers.toUtf8Bytes("my-server"))'); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts new file mode 100644 index 0000000..0d05283 --- /dev/null +++ b/test/rewardsManager.test.ts @@ -0,0 +1,230 @@ +import { expect } from 'chai'; +import { ethers, upgrades } from 'hardhat'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; + +describe('RewardsManager', function () { + const SERVER_ID = ethers.keccak256(ethers.toUtf8Bytes('server-1')); + + async function deployRewardsManagerFixture() { + const [devWallet, managerWallet, user1, user2] = await ethers.getSigners(); + + const RewardsManager = await ethers.getContractFactory('RewardsManager'); + const manager = await upgrades.deployProxy( + RewardsManager, + [devWallet.address, managerWallet.address], + { kind: 'uups', initializer: 'initialize' } + ); + await manager.waitForDeployment(); + + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const rewardsServerImpl = await RewardsServerImpl.deploy(); + await rewardsServerImpl.waitForDeployment(); + + await manager.connect(devWallet).initializeBeacons(await rewardsServerImpl.getAddress()); + + const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); + const factory = await RewardsFactory.deploy(await manager.getAddress()); + await factory.waitForDeployment(); + await factory.setBeacons(await manager.treasuryBeacon()); + await manager.connect(devWallet).grantRole(await manager.FACTORY_ROLE(), await factory.getAddress()); + + return { + manager, + factory, + devWallet, + managerWallet, + user1, + user2, + }; + } + + describe('deployServer', function () { + it('should deploy a server with RewardsServer', async function () { + const { manager, factory, user1 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(user1).deployServer(SERVER_ID); + + const serverAddr = await manager.getServer(SERVER_ID); + expect(serverAddr).to.properAddress; + }); + + it('should revert when serverId already exists', async function () { + const { manager, factory, user1 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(user1).deployServer(SERVER_ID); + await expect(factory.connect(user1).deployServer(SERVER_ID)) + .to.be.revertedWithCustomError(manager, 'ServerAlreadyExists'); + }); + + it('should revert when serverId is zero', async function () { + const { manager, factory, user1 } = await loadFixture(deployRewardsManagerFixture); + await expect(factory.connect(user1).deployServer(ethers.ZeroHash)) + .to.be.revertedWithCustomError(manager, 'InvalidServerId'); + }); + }); + + describe('server admin and signers', function () { + it('server admin can set signer and withdrawer', async function () { + const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(user1).deployServer(SERVER_ID); + + await manager.connect(user1).setServerSigner(SERVER_ID, user2.address, true); + expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.true; + + await manager.connect(user1).setServerWithdrawer(SERVER_ID, user2.address, true); + expect(await manager.isServerWithdrawer(SERVER_ID, user2.address)).to.be.true; + }); + + it('non-admin cannot set signer', async function () { + const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(user1).deployServer(SERVER_ID); + const serverAddr = await manager.getServer(SERVER_ID); + const server = await ethers.getContractAt('RewardsServer', serverAddr); + await expect( + manager.connect(user2).setServerSigner(SERVER_ID, user2.address, true) + ).to.be.revertedWithCustomError(server, 'UnauthorizedServerAdmin'); + }); + }); + + /** Build claim data and signature for claim(). Signer must be set as server signer. */ + async function buildClaimDataAndSignature( + manager: Awaited>, + serverId: string, + signer: Awaited>[0], + beneficiary: string, + tokenIds: number[], + expiration: number, + nonce: number + ) { + const chainId = (await ethers.provider.getNetwork()).chainId; + const managerAddress = await manager.getAddress(); + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint256[]'], + [managerAddress, chainId, beneficiary, expiration, tokenIds] + ); + const messageHash = ethers.keccak256( + ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'bytes32', 'address', 'uint256', 'uint256[]', 'uint256'], + [managerAddress, chainId, serverId, beneficiary, expiration, tokenIds, nonce] + ) + ); + const signature = await signer.signMessage(ethers.getBytes(messageHash)); + return { data, signature }; + } + + describe('treasury and reward flow', function () { + async function deployWithServerAndTokenFixture() { + const base = await loadFixture(deployRewardsManagerFixture); + await base.factory.connect(base.user1).deployServer(SERVER_ID); + await base.manager.connect(base.user1).setServerSigner(SERVER_ID, 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 base.manager + .connect(base.managerWallet) + .whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1); // ERC20 + await mockERC20 + .connect(base.managerWallet) + .approve(await base.manager.getServer(SERVER_ID), ethers.parseEther('1000')); + await base.manager + .connect(base.managerWallet) + .depositToTreasury(SERVER_ID, await mockERC20.getAddress(), ethers.parseEther('1000')); + + return { ...base, mockERC20 }; + } + + 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, 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 manager + .connect(managerWallet) + .createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + + expect(await manager.isTokenExist(SERVER_ID, tokenId)).to.be.true; + + const expiration = Math.floor(Date.now() / 1000) + 3600; + const { data, signature } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 0 + ); + const before = await mockERC20.balanceOf(user1.address); + await manager.connect(user1).claim(SERVER_ID, data, 0, signature); + const after_ = await mockERC20.balanceOf(user1.address); + expect(after_ - before).to.equal(ethers.parseEther('10')); + }); + + it('should allow multiple claims with different nonces', async function () { + const { manager, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + const expiration = Math.floor(Date.now() / 1000) + 3600; + + const { data: data1, signature: sig1 } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 0 + ); + const { data: data2, signature: sig2 } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 1 + ); + await manager.connect(user1).claim(SERVER_ID, data1, 0, sig1); + await manager.connect(user1).claim(SERVER_ID, data2, 1, sig2); + + expect(await mockERC20.balanceOf(user1.address)).to.equal(ethers.parseEther('20')); + }); + }); +}); From b93fbe6824b07fc06ae4ff157ff6f16087597134 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Wed, 18 Feb 2026 17:36:15 -0300 Subject: [PATCH 2/9] Feat: Audit improvements --- .../soulbounds/RewardsFactory.sol | 28 +++- .../soulbounds/RewardsManager.sol | 116 ++++++++++---- .../upgradeables/soulbounds/RewardsServer.sol | 150 ++++++++++++------ scripts/deployRewardsManager.ts | 2 +- 4 files changed, 217 insertions(+), 79 deletions(-) diff --git a/contracts/upgradeables/soulbounds/RewardsFactory.sol b/contracts/upgradeables/soulbounds/RewardsFactory.sol index 65525d2..def6c4c 100644 --- a/contracts/upgradeables/soulbounds/RewardsFactory.sol +++ b/contracts/upgradeables/soulbounds/RewardsFactory.sol @@ -10,7 +10,8 @@ import { RewardsServer } from "./RewardsServer.sol"; /** * @title RewardsFactory - * @notice Deploys per-server RewardsServer, registers with RewardsManager. + * @notice Deploys per-server RewardsServer (BeaconProxy) and registers each server with RewardsManager. + * @dev Caller of deployServer becomes the server admin. Requires FACTORY_ROLE on the manager to be granted to this contract. */ interface IRewardsManagerFactory { function registerServer(bytes32 serverId, address treasury) external; @@ -24,11 +25,14 @@ contract RewardsFactory { address public immutable manager; address public owner; + address public pendingOwner; UpgradeableBeacon public treasuryBeacon; event BeaconsSet(address treasuryBeacon); event ServerDeployed(bytes32 indexed serverId, address indexed serverAdmin); + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); constructor(address _manager) { if (_manager == address(0)) revert AddressIsZero(); @@ -41,6 +45,8 @@ contract RewardsFactory { _; } + /// @notice Sets the UpgradeableBeacon used for RewardsServer implementations. Callable only once by owner. + /// @param _treasuryBeacon Address of the deployed RewardsServer beacon. function setBeacons(address _treasuryBeacon) external onlyOwner { if (_treasuryBeacon == address(0)) revert AddressIsZero(); if (address(treasuryBeacon) != address(0)) revert BeaconsAlreadySet(); @@ -48,14 +54,26 @@ contract RewardsFactory { emit BeaconsSet(_treasuryBeacon); } + /// @notice Starts a two-step ownership transfer. New owner must call acceptOwnership. + /// @param newOwner Address that will be able to call acceptOwnership. function transferOwnership(address newOwner) external onlyOwner { if (newOwner == address(0)) revert AddressIsZero(); - owner = newOwner; + pendingOwner = newOwner; + emit OwnershipTransferStarted(owner, newOwner); } - /** - * @dev Deploy a new server: RewardsServer proxy. Register with manager. Caller = server admin. - */ + /// @notice Completes ownership transfer. Callable only by the address set via transferOwnership. + function acceptOwnership() external { + if (msg.sender != pendingOwner) revert Unauthorized(); + address previousOwner = owner; + owner = pendingOwner; + delete pendingOwner; + emit OwnershipTransferred(previousOwner, owner); + } + + /// @notice Deploys a new RewardsServer (BeaconProxy) for the given server and registers it with the RewardsManager. + /// @dev Caller becomes the server admin. Requires FACTORY_ROLE on the manager to be granted to this contract. + /// @param serverId Unique identifier for the server (e.g. keccak256 of a string id). function deployServer(bytes32 serverId) external { if (address(treasuryBeacon) == address(0)) revert BeaconNotSet(); diff --git a/contracts/upgradeables/soulbounds/RewardsManager.sol b/contracts/upgradeables/soulbounds/RewardsManager.sol index 523e792..a746f75 100644 --- a/contracts/upgradeables/soulbounds/RewardsManager.sol +++ b/contracts/upgradeables/soulbounds/RewardsManager.sol @@ -73,6 +73,8 @@ contract RewardsManager is /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ + uint256 public constant MAX_CLAIM_TOKEN_IDS = 50; + bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); bytes32 public constant DEV_CONFIG_ROLE = keccak256("DEV_CONFIG_ROLE"); @@ -103,7 +105,10 @@ contract RewardsManager is // Global signature replay protection mapping(bytes => bool) private usedSignatures; - uint256[45] private __gap; + // ETH reserved for pending claims (withdrawals cannot exceed balance - this) + uint256 private ethReservedTotal; + + uint256[44] private __gap; /*////////////////////////////////////////////////////////////// EVENTS @@ -180,6 +185,9 @@ contract RewardsManager is _disableInitializers(); } + /// @notice Initializes roles (dev, manager, upgrader). Called once by the proxy. + /// @param _devWallet Receives DEFAULT_ADMIN_ROLE, DEV_CONFIG_ROLE, UPGRADER_ROLE. + /// @param _managerWallet Receives MANAGER_ROLE, MINTER_ROLE. function initialize( address _devWallet, address _managerWallet @@ -210,9 +218,8 @@ contract RewardsManager is BEACON CONFIGURATION //////////////////////////////////////////////////////////////*/ - /** - * @dev Initialize beacon for RewardsServer implementation. Can only be called once by DEV_CONFIG_ROLE. - */ + /// @notice Sets the RewardsServer implementation beacon. Callable once by DEV_CONFIG_ROLE. + /// @param _treasuryImplementation Implementation contract for RewardsServer (BeaconProxy targets this). function initializeBeacons(address _treasuryImplementation) external onlyRole(DEV_CONFIG_ROLE) { if (address(_treasuryImplementation) == address(0)) { revert AddressIsZero(); @@ -240,9 +247,9 @@ contract RewardsManager is } } - /** - * @dev Register a server deployed by RewardsFactory. Manager already has REWARDS_MANAGER_ROLE from server.initialize(..., manager, ...). - */ + /// @notice Registers a new server (RewardsServer proxy) for the given serverId. Only FACTORY_ROLE (RewardsFactory). + /// @param serverId Unique server identifier. + /// @param treasury Address of the deployed RewardsServer proxy. function registerServer(bytes32 serverId, address treasury) external onlyRole(FACTORY_ROLE) { if (serverId == bytes32(0)) revert InvalidServerId(); if (servers[serverId].exists) revert ServerAlreadyExists(); @@ -253,9 +260,7 @@ contract RewardsManager is emit ServerDeployed(serverId, treasury); } - /** - * @dev Transfer server admin to a new address. Permission checked by RewardsServer. - */ + /// @notice Transfers server admin to newAdmin. Caller must be current SERVER_ADMIN_ROLE on the server. function transferServerAdmin(bytes32 serverId, address newAdmin) external { if (newAdmin == address(0)) revert AddressIsZero(); Server storage s = _getServer(serverId); @@ -263,9 +268,7 @@ contract RewardsManager is emit ServerAdminTransferred(serverId, msg.sender, newAdmin); } - /** - * @dev View function to get server treasury (and admin via serverId). - */ + /// @notice Returns the RewardsServer (treasury) address for a server. function getServer(bytes32 serverId) external view returns (address treasury) { Server storage s = _getServer(serverId); return s.treasury; @@ -275,6 +278,7 @@ contract RewardsManager is PER-SERVER ACCESS CONTROL //////////////////////////////////////////////////////////////*/ + /// @notice Enables or disables a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. function setServerSigner( bytes32 serverId, address signer, @@ -285,11 +289,13 @@ contract RewardsManager is emit ServerSignerUpdated(serverId, signer, isActive); } + /// @notice Returns whether the address is an active signer for the server. function isServerSigner(bytes32 serverId, address signer) external view returns (bool) { Server storage s = _getServer(serverId); return RewardsServer(s.treasury).isSigner(signer); } + /// @notice Enables or disables a withdrawer for the server. Caller must be SERVER_ADMIN_ROLE on the server. function setServerWithdrawer( bytes32 serverId, address account, @@ -300,6 +306,7 @@ contract RewardsManager is emit ServerWithdrawerUpdated(serverId, account, isActive); } + /// @notice Returns whether the account is an active withdrawer for the server. function isServerWithdrawer(bytes32 serverId, address account) external view returns (bool) { Server storage s = _getServer(serverId); return RewardsServer(s.treasury).isWithdrawer(account); @@ -358,10 +365,12 @@ contract RewardsManager is 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(); } @@ -370,6 +379,7 @@ contract RewardsManager is TREASURY MANAGEMENT (PER TENANT) //////////////////////////////////////////////////////////////*/ + /// @notice Adds a token to the server whitelist. Only MANAGER_ROLE. function whitelistToken( bytes32 serverId, address token, @@ -379,6 +389,7 @@ contract RewardsManager is RewardsServer(s.treasury).whitelistToken(token, rewardType); } + /// @notice Removes a token from the server whitelist (fails if token has reserves). Only MANAGER_ROLE. function removeTokenFromWhitelist( bytes32 serverId, address token @@ -387,6 +398,7 @@ contract RewardsManager is RewardsServer(s.treasury).removeTokenFromWhitelist(token); } + /// @notice Deposits ERC20 from msg.sender into the server treasury. Token must be whitelisted. Reentrancy-protected. function depositToTreasury( bytes32 serverId, address token, @@ -400,6 +412,9 @@ contract RewardsManager is REWARD TOKEN CREATION AND SUPPLY (PER TENANT) //////////////////////////////////////////////////////////////*/ + /// @notice Creates a new reward token on the server and reserves/deposits rewards. Send ETH if token has ETHER rewards. Only MANAGER_ROLE. + /// @param serverId Server id. + /// @param token Reward token definition (tokenId, maxSupply, rewards, tokenUri). ERC721 rewards require exact rewardTokenIds length = rewardAmount * maxSupply. function createTokenAndDepositRewards( bytes32 serverId, LibItems.RewardToken calldata token @@ -408,9 +423,11 @@ contract RewardsManager is if (msg.value < ethRequired) revert InsufficientBalance(); Server storage s = _getServer(serverId); _validateAndCreateTokenAndDepositRewards(s.treasury, token); + ethReservedTotal += ethRequired; emit TokenAdded(serverId, token.tokenId); } + /// @notice Pauses or unpauses minting for a reward token. Only MANAGER_ROLE. function updateTokenMintPaused( bytes32 serverId, uint256 tokenId, @@ -421,6 +438,7 @@ contract RewardsManager is emit TokenMintPausedUpdated(serverId, tokenId, isPaused); } + /// @notice Pauses or unpauses claiming for a reward token. Only MANAGER_ROLE. function updateClaimRewardPaused( bytes32 serverId, uint256 tokenId, @@ -431,19 +449,36 @@ contract RewardsManager is emit ClaimRewardPausedUpdated(serverId, tokenId, isPaused); } + /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155/ETH. Only MANAGER_ROLE. Not supported for ERC721-backed rewards (create a new token instead). + /// @param serverId Server id. + /// @param tokenId Reward token id. + /// @param additionalSupply Amount to add. Send ETH if token has ETHER rewards (amount = sum of ETHER rewardAmount * additionalSupply). function increaseRewardSupply( bytes32 serverId, uint256 tokenId, uint256 additionalSupply - ) external onlyRole(MANAGER_ROLE) { + ) external payable onlyRole(MANAGER_ROLE) { Server storage s = _getServer(serverId); RewardsServer serverContract = RewardsServer(s.treasury); if (!serverContract.isTokenExists(tokenId)) revert TokenNotExist(); - uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; + if (additionalSupply == 0) revert InvalidAmount(); + LibItems.RewardToken memory rewardToken = serverContract.getRewardToken(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; + } + } + if (additionalEthRequired > 0) { + if (msg.value < additionalEthRequired) revert InsufficientBalance(); + ethReservedTotal += additionalEthRequired; + } + uint256 oldSupply = rewardToken.maxSupply; serverContract.increaseRewardSupply(tokenId, additionalSupply); emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply + additionalSupply); } + /// @notice Updates the token URI for a reward token. Only MANAGER_ROLE. function updateTokenUri( bytes32 serverId, uint256 tokenId, @@ -555,6 +590,7 @@ contract RewardsManager is for (uint256 i = 0; i < rewards.length; i++) { LibItems.Reward memory r = rewards[i]; if (r.rewardType == LibItems.RewardType.ETHER) { + ethReservedTotal -= r.rewardAmount; _transferEther(payable(to), r.rewardAmount); } else if (r.rewardType == LibItems.RewardType.ERC20) { serverContract.distributeERC20(r.rewardTokenAddress, to, r.rewardAmount); @@ -568,7 +604,7 @@ contract RewardsManager is serverContract.releaseERC721(r.rewardTokenAddress, nftId); serverContract.distributeERC721(r.rewardTokenAddress, to, nftId); } - serverContract.incrementERC721RewardIndex(rewardTokenId, i); + serverContract.incrementERC721RewardIndex(rewardTokenId, i, r.rewardAmount); } else if (r.rewardType == LibItems.RewardType.ERC1155) { serverContract.decreaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, r.rewardAmount); serverContract.distributeERC1155(r.rewardTokenAddress, to, r.rewardTokenId, r.rewardAmount); @@ -604,10 +640,12 @@ contract RewardsManager is abi.decode(data, (address, uint256, address, uint256, uint256[])); } - /** - * @dev Permissionless claim: beneficiary presents server signature and receives rewards for each tokenId. - * Caller must be the beneficiary. No AccessToken; rewards are distributed directly from treasury. - */ + /// @notice Permissionless claim: beneficiary presents server signature and receives one unit of reward per tokenId. + /// @dev Caller must be beneficiary. Signature is burned (replay protection). tokenIds length capped by MAX_CLAIM_TOKEN_IDS. + /// @param serverId Server id. + /// @param data ABI-encoded (contractAddress, chainId, beneficiary, expiration, tokenIds). + /// @param nonce User nonce (must not be used before). + /// @param signature Server signer signature over the claim message. function claim( bytes32 serverId, bytes calldata data, @@ -623,15 +661,15 @@ contract RewardsManager is ) = _decodeClaimData(data); if (contractAddress != address(this) || chainId != block.chainid) revert InvalidInput(); - if (block.timestamp >= expiration) revert InvalidSignature(); if (msg.sender != beneficiary) revert InvalidInput(); - - _verifyServerSignature(serverId, beneficiary, expiration, tokenIds, nonce, signature); - usedSignatures[signature] = true; + if (tokenIds.length > MAX_CLAIM_TOKEN_IDS) revert InvalidInput(); Server storage s = _getServer(serverId); RewardsServer serverContract = RewardsServer(s.treasury); if (serverContract.userNonces(beneficiary, nonce)) revert NonceAlreadyUsed(); + + _verifyServerSignature(serverId, beneficiary, expiration, tokenIds, nonce, signature); + usedSignatures[signature] = true; serverContract.setUserNonce(beneficiary, nonce, true); for (uint256 i = 0; i < tokenIds.length; i++) { @@ -640,6 +678,7 @@ contract RewardsManager is } } + /// @notice Withdraws assets from manager (ETHER) or server treasury (ERC20/721/1155) to recipient. Only MANAGER_ROLE. ETHER: amounts[0]; ERC721: tokenIds; ERC1155: tokenIds + amounts. function withdrawAssets( bytes32 serverId, LibItems.RewardType rewardType, @@ -653,7 +692,10 @@ contract RewardsManager is RewardsServer serverContract = RewardsServer(s.treasury); if (rewardType == LibItems.RewardType.ETHER) { - _transferEther(payable(to), amounts[0]); + if (amounts.length == 0) revert InvalidInput(); + uint256 amount = amounts[0]; + if (amount > address(this).balance - ethReservedTotal) revert InsufficientBalance(); + _transferEther(payable(to), amount); } else if (rewardType == LibItems.RewardType.ERC20) { serverContract.withdrawUnreservedTreasury(tokenAddress, to); } else if (rewardType == LibItems.RewardType.ERC721) { @@ -666,13 +708,17 @@ contract RewardsManager is serverContract.withdrawERC1155UnreservedTreasury(tokenAddress, to, tokenIds[i], amounts[i]); } } - emit AssetsWithdrawn(serverId, rewardType, to, amounts.length > 0 ? amounts[0] : 0); + uint256 emittedAmount = rewardType == LibItems.RewardType.ERC721 + ? tokenIds.length + : (amounts.length > 0 ? amounts[0] : 0); + emit AssetsWithdrawn(serverId, rewardType, to, emittedAmount); } /*////////////////////////////////////////////////////////////// TREASURY WITHDRAW (PER TENANT) //////////////////////////////////////////////////////////////*/ + /// @notice Withdraws all unreserved ERC20 for token to to. Caller must be server withdrawer. Reentrancy-protected. function withdrawUnreservedTreasury( bytes32 serverId, address token, @@ -683,6 +729,7 @@ contract RewardsManager is RewardsServer(s.treasury).withdrawUnreservedTreasury(token, to); } + /// @notice Withdraws one unreserved ERC721 (tokenId) to to. Caller must be server withdrawer. Reentrancy-protected. function withdrawERC721UnreservedTreasury( bytes32 serverId, address token, @@ -694,6 +741,7 @@ contract RewardsManager is RewardsServer(s.treasury).withdrawERC721UnreservedTreasury(token, to, tokenId); } + /// @notice Withdraws unreserved ERC1155 amount to to. Caller must be server withdrawer. Reentrancy-protected. function withdrawERC1155UnreservedTreasury( bytes32 serverId, address token, @@ -710,6 +758,7 @@ contract RewardsManager is VIEW HELPERS (PER TENANT) //////////////////////////////////////////////////////////////*/ + /// @notice Returns full treasury balance view for the server (addresses, total/reserved/available, symbols, names, types, tokenIds). function getServerTreasuryBalances( bytes32 serverId ) @@ -727,14 +776,16 @@ contract RewardsManager is ) { Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getAllTreasuryBalances(s.treasury); + return RewardsServer(s.treasury).getAllTreasuryBalances(); } + /// @notice Returns all reward token ids (item ids) for the server. function getServerAllItemIds(bytes32 serverId) external view returns (uint256[] memory) { Server storage s = _getServer(serverId); return RewardsServer(s.treasury).getAllItemIds(); } + /// @notice Returns reward definitions for a reward token on the server. function getServerTokenRewards( bytes32 serverId, uint256 tokenId @@ -743,6 +794,7 @@ contract RewardsManager is return RewardsServer(s.treasury).getTokenRewards(tokenId); } + /// @notice Returns server treasury ERC20 balance for token. function getServerTreasuryBalance( bytes32 serverId, address token @@ -755,6 +807,7 @@ contract RewardsManager is ); } + /// @notice Returns reserved amount for token on the server. function getServerReservedAmount( bytes32 serverId, address token @@ -767,6 +820,7 @@ contract RewardsManager is ); } + /// @notice Returns unreserved (available) treasury balance for token on the server. function getServerAvailableTreasuryBalance( bytes32 serverId, address token @@ -779,6 +833,7 @@ contract RewardsManager is ); } + /// @notice Returns whitelisted token addresses for the server. function getServerWhitelistedTokens( bytes32 serverId ) external view returns (address[] memory) { @@ -789,6 +844,7 @@ contract RewardsManager is ); } + /// @notice Returns whether token is whitelisted on the server. function isServerWhitelistedToken( bytes32 serverId, address token @@ -801,11 +857,13 @@ contract RewardsManager is ); } + /// @notice Returns whether the reward token exists on the server. function isTokenExist(bytes32 serverId, uint256 tokenId) public view returns (bool) { Server storage s = _getServer(serverId); return RewardsServer(s.treasury).isTokenExists(tokenId); } + /// @notice Returns structured reward token details (URI, maxSupply, reward types/amounts/addresses/tokenIds). function getTokenDetails( bytes32 serverId, uint256 tokenId @@ -841,7 +899,7 @@ contract RewardsManager is } } - /// @dev True if token exists, claim is not paused, and there is remaining supply to claim. + /// @notice Returns true if the reward token exists, claim is not paused, and there is remaining supply to claim. function canUserClaim( bytes32 serverId, address, @@ -856,6 +914,7 @@ contract RewardsManager is return current < maxSupply; } + /// @notice Returns remaining claimable supply for a reward token (maxSupply - currentSupply), or 0 if exhausted/nonexistent. function getRemainingSupply( bytes32 serverId, uint256 tokenId @@ -869,6 +928,7 @@ contract RewardsManager is return maxSupply - current; } + /// @notice Returns whether the user has already used the given nonce (replay protection). function isNonceUsed( bytes32 serverId, address user, diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol index d12ebfa..b443d78 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -12,7 +12,6 @@ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/I 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 { UUPSUpgradeable } from "@openzeppelin/contracts/proxy/utils/UUPSUpgradeable.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"; @@ -31,9 +30,10 @@ interface IERC1155Metadata { * @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. - * This contract is upgradeable using the UUPS pattern. + * 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, UUPSUpgradeable, ERC721HolderUpgradeable, ERC1155HolderUpgradeable { +contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderUpgradeable, ERC1155HolderUpgradeable { /*////////////////////////////////////////////////////////////// ERRORS @@ -48,12 +48,12 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab error TokenHasReserves(); error InsufficientBalance(); error UnauthorizedServerAdmin(); + error InsufficientERC721Ids(); /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); - bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); bytes32 public constant SERVER_ADMIN_ROLE = keccak256("SERVER_ADMIN_ROLE"); /*////////////////////////////////////////////////////////////// @@ -106,6 +106,10 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab _disableInitializers(); } + /// @notice Initializes the server: access roles and server admin. Called once by the proxy. + /// @param _admin Default admin (e.g. RewardsManager or deployer). + /// @param _rewardsContract Address that receives REWARDS_MANAGER_ROLE (typically RewardsManager). + /// @param _serverAdmin Address that receives SERVER_ADMIN_ROLE (signers, withdrawers, transfer). function initialize(address _admin, address _rewardsContract, address _serverAdmin) external initializer { if (_admin == address(0) || _rewardsContract == address(0) || _serverAdmin == address(0)) { revert AddressIsZero(); @@ -116,29 +120,34 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab __ERC1155Holder_init(); _grantRole(DEFAULT_ADMIN_ROLE, _admin); - _grantRole(UPGRADER_ROLE, _admin); _grantRole(REWARDS_MANAGER_ROLE, _rewardsContract); _grantRole(SERVER_ADMIN_ROLE, _serverAdmin); } - function _authorizeUpgrade(address newImplementation) internal override onlyRole(UPGRADER_ROLE) {} - /*////////////////////////////////////////////////////////////// SERVER ACCESS CONTROL (admin, signers, withdrawers) //////////////////////////////////////////////////////////////*/ + /// @notice Enables or disables a claim signer. Only SERVER_ADMIN_ROLE. + /// @param account Signer address. + /// @param active True to allow signing, false to revoke. function setSigner(address account, bool active) external onlyRole(SERVER_ADMIN_ROLE) { if (account == address(0)) revert AddressIsZero(); signers[account] = active; emit SignerUpdated(account, active); } + /// @notice Enables or disables a withdrawer. Only SERVER_ADMIN_ROLE. + /// @param account Withdrawer address. + /// @param active True to allow withdrawals, false to revoke. function setWithdrawer(address account, bool active) external onlyRole(SERVER_ADMIN_ROLE) { if (account == address(0)) revert AddressIsZero(); withdrawers[account] = active; emit WithdrawerUpdated(account, active); } + /// @notice Transfers SERVER_ADMIN_ROLE to another address. Caller loses the role. + /// @param newAdmin Address to receive SERVER_ADMIN_ROLE. function transferServerAdmin(address newAdmin) external onlyRole(SERVER_ADMIN_ROLE) { if (newAdmin == address(0)) revert AddressIsZero(); address oldAdmin = msg.sender; @@ -147,14 +156,17 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab emit ServerAdminTransferred(oldAdmin, newAdmin); } + /// @notice Returns whether the account is an active signer for claims. function isSigner(address account) external view returns (bool) { return signers[account]; } + /// @notice Returns whether the account is an active withdrawer. function isWithdrawer(address account) external view returns (bool) { return withdrawers[account]; } + /// @notice Same as setSigner but called by RewardsManager; caller must be SERVER_ADMIN_ROLE. function setSignerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); if (account == address(0)) revert AddressIsZero(); @@ -162,6 +174,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab emit SignerUpdated(account, active); } + /// @notice Same as setWithdrawer but called by RewardsManager; caller must be SERVER_ADMIN_ROLE. function setWithdrawerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); if (account == address(0)) revert AddressIsZero(); @@ -169,6 +182,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab emit WithdrawerUpdated(account, active); } + /// @notice Same as transferServerAdmin but initiated by RewardsManager; caller becomes new admin. function transferServerAdminAllowedBy(address caller, address newAdmin) external onlyRole(REWARDS_MANAGER_ROLE) { if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); if (newAdmin == address(0)) revert AddressIsZero(); @@ -181,6 +195,9 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab TREASURY MANAGEMENT FUNCTIONS //////////////////////////////////////////////////////////////*/ + /// @notice Adds a token to the whitelist for use in rewards. Only REWARDS_MANAGER_ROLE. + /// @param _token Token contract address. + /// @param _type One of ERC20, ERC721, ERC1155. function whitelistToken(address _token, LibItems.RewardType _type) external onlyRole(REWARDS_MANAGER_ROLE) { if (_token == address(0)) revert AddressIsZero(); if (whitelistedTokens[_token]) revert TokenAlreadyWhitelisted(); @@ -194,6 +211,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab } } + /// @notice Removes a token from the whitelist. Fails if the token has any reserves. Only REWARDS_MANAGER_ROLE. + /// @param _token Token contract address. function removeTokenFromWhitelist(address _token) external onlyRole(REWARDS_MANAGER_ROLE) { LibItems.RewardType _type = tokenTypes[_token]; if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -203,6 +222,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab 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]; @@ -212,6 +232,10 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab } } + /// @notice Transfers tokens from _from into this treasury. Only REWARDS_MANAGER_ROLE. Token must be whitelisted. + /// @param _token Token address (ERC20). + /// @param _amount Amount to transfer. + /// @param _from Source address (must have approved this contract). function depositToTreasury(address _token, uint256 _amount, address _from) external onlyRole(REWARDS_MANAGER_ROLE) { if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); if (_amount == 0) revert InvalidAmount(); @@ -220,6 +244,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab emit TreasuryDeposit(_token, _amount); } + /// @notice Sends all unreserved ERC20 balance of _token to _to. Only REWARDS_MANAGER_ROLE. function withdrawUnreservedTreasury(address _token, address _to) external onlyRole(REWARDS_MANAGER_ROLE) { if (_to == address(0)) revert AddressIsZero(); if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -232,6 +257,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab SafeERC20.safeTransfer(IERC20(_token), _to, balance - reserved); } + /// @notice Sends one unreserved ERC721 token to _to. Only REWARDS_MANAGER_ROLE. Fails if _tokenId is reserved. function withdrawERC721UnreservedTreasury(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { if (_to == address(0)) revert AddressIsZero(); if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -240,6 +266,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); } + /// @notice Sends unreserved ERC1155 amount to _to. Only REWARDS_MANAGER_ROLE. function withdrawERC1155UnreservedTreasury(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { if (_to == address(0)) revert AddressIsZero(); if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -257,14 +284,17 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab DISTRIBUTION FUNCTIONS (for claims) //////////////////////////////////////////////////////////////*/ + /// @notice Transfers ERC20 to recipient (used when fulfilling claims). Only REWARDS_MANAGER_ROLE. function distributeERC20(address _token, address _to, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { SafeERC20.safeTransfer(IERC20(_token), _to, _amount); } + /// @notice Transfers ERC721 to recipient (used when fulfilling claims). Only REWARDS_MANAGER_ROLE. function distributeERC721(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); } + /// @notice Transfers ERC1155 amount to recipient (used when fulfilling claims). Only REWARDS_MANAGER_ROLE. function distributeERC1155(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { IERC1155(_token).safeTransferFrom(address(this), _to, _tokenId, _amount, ""); } @@ -273,7 +303,16 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab TREASURY VIEW FUNCTIONS //////////////////////////////////////////////////////////////*/ - function getAllTreasuryBalances(address rewardsContract) + /// @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 ( @@ -297,7 +336,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab } } - uint256 erc1155Count = _countUniqueErc1155TokenIds(rewardsContract); + uint256 erc1155Count = _countUniqueErc1155TokenIds(); uint256 totalCount = erc20AndErc721Count + erc1155Count; addresses = new address[](totalCount); @@ -318,39 +357,44 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab addresses[currentIndex] = tokenAddress; if (tokenType == LibItems.RewardType.ERC20) { - _processERC20Token(rewardsContract, tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); + _processERC20Token(tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); tokenIds[currentIndex] = 0; currentIndex++; } else if (tokenType == LibItems.RewardType.ERC721) { - _processERC721Token(rewardsContract, tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); + _processERC721Token(tokenAddress, currentIndex, totalBalances, reservedBalances, availableBalances, symbols, names, types); tokenIds[currentIndex] = 0; currentIndex++; } } - currentIndex = _processERC1155Tokens(rewardsContract, erc1155Count, currentIndex, addresses, totalBalances, reservedBalances, availableBalances, symbols, names, types, tokenIds); + 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 (IRewards-compatible signature; first param ignored). function getTreasuryBalance(address, address _token) external view returns (uint256) { return IERC20(_token).balanceOf(address(this)); } + /// @notice Reserved amount for _token (IRewards-compatible signature; first param ignored). function getReservedAmount(address, address _token) external view returns (uint256) { return reservedAmounts[_token]; } + /// @notice Unreserved ERC20 balance for _token (IRewards-compatible signature; first param ignored). function getAvailableTreasuryBalance(address, 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 (IRewards-compatible; first param ignored). function getWhitelistedTokens(address) external view returns (address[] memory) { return whitelistedTokenList; } + /// @notice Whether _token is whitelisted (IRewards-compatible; first param ignored). function isWhitelistedToken(address, address _token) external view returns (bool) { return whitelistedTokens[_token]; } @@ -359,22 +403,27 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab REWARD RESERVE & VIEW (for IRewards) //////////////////////////////////////////////////////////////*/ + /// @notice All reward token ids (item ids) defined on this server. IRewards interface. function getAllItemIds() external view returns (uint256[] memory) { return itemIds; } + /// @notice Reward definitions for a given reward token id. IRewards interface. 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 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]; } @@ -383,42 +432,41 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab RESERVATION & REWARD MUTATORS (REWARDS_MANAGER_ROLE) //////////////////////////////////////////////////////////////*/ + /// @notice Increases reserved ERC20 amount for _token (e.g. when creating or extending reward supply). Only REWARDS_MANAGER_ROLE. function increaseERC20Reserved(address _token, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { reservedAmounts[_token] += _amount; } + /// @notice Decreases reserved ERC20 amount. Only REWARDS_MANAGER_ROLE. Reverts if would go below zero. function decreaseERC20Reserved(address _token, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { - if (reservedAmounts[_token] >= _amount) { - reservedAmounts[_token] -= _amount; - } + reservedAmounts[_token] -= _amount; } + /// @notice Marks an ERC721 token id as reserved for rewards. Only REWARDS_MANAGER_ROLE. function reserveERC721(address _token, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { isErc721Reserved[_token][_tokenId] = true; erc721TotalReserved[_token]++; } + /// @notice Releases reservation of an ERC721 token id. Only REWARDS_MANAGER_ROLE. function releaseERC721(address _token, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { isErc721Reserved[_token][_tokenId] = false; - if (erc721TotalReserved[_token] > 0) { - erc721TotalReserved[_token]--; - } + erc721TotalReserved[_token]--; } + /// @notice Increases reserved ERC1155 amount for (_token, _tokenId). Only REWARDS_MANAGER_ROLE. function increaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { erc1155ReservedAmounts[_token][_tokenId] += _amount; erc1155TotalReserved[_token] += _amount; } + /// @notice Decreases reserved ERC1155 amount. Only REWARDS_MANAGER_ROLE. Reverts if would go below zero. function decreaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { - if (erc1155ReservedAmounts[_token][_tokenId] >= _amount) { - erc1155ReservedAmounts[_token][_tokenId] -= _amount; - } - if (erc1155TotalReserved[_token] >= _amount) { - erc1155TotalReserved[_token] -= _amount; - } + erc1155ReservedAmounts[_token][_tokenId] -= _amount; + erc1155TotalReserved[_token] -= _amount; } + /// @notice Registers a new reward token (item) with given id and reward definition. Only REWARDS_MANAGER_ROLE. Id must not exist. function addRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) external onlyRole(REWARDS_MANAGER_ROLE) { if (tokenExists[_tokenId]) revert RewardTokenAlreadyExists(); @@ -428,14 +476,16 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab currentRewardSupply[_tokenId] = 0; } + /// @notice Updates reward definition for an existing reward token (e.g. URI). Only REWARDS_MANAGER_ROLE. Does not extend ERC721 reward ids. function updateRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) external onlyRole(REWARDS_MANAGER_ROLE) { if (!tokenExists[_tokenId]) revert TokenNotWhitelisted(); tokenRewards[_tokenId] = _rewardToken; } - /** - * @dev Increase max supply for a reward token; reserves additional ERC20/ERC1155 on this server. - */ + /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155 on this server. Only REWARDS_MANAGER_ROLE. + /// @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 onlyRole(REWARDS_MANAGER_ROLE) { if (!tokenExists[_tokenId]) revert TokenNotExist(); if (_additionalSupply == 0) revert InvalidAmount(); @@ -445,7 +495,9 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab for (uint256 i = 0; i < rewardToken.rewards.length; i++) { LibItems.Reward memory r = rewardToken.rewards[i]; - if (r.rewardType == LibItems.RewardType.ERC20) { + 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]; @@ -465,30 +517,34 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab tokenRewards[_tokenId] = rewardToken; } + /// @notice Pauses or unpauses minting for a reward token. Only REWARDS_MANAGER_ROLE. function setTokenMintPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_MANAGER_ROLE) { isTokenMintPaused[_tokenId] = _isPaused; } + /// @notice Pauses or unpauses claiming rewards for a reward token. Only REWARDS_MANAGER_ROLE. function setClaimRewardPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_MANAGER_ROLE) { isClaimRewardPaused[_tokenId] = _isPaused; } + /// @notice Increments current minted supply for a reward token (e.g. after mint). Only REWARDS_MANAGER_ROLE. function increaseCurrentSupply(uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { currentRewardSupply[_tokenId] += _amount; } + /// @notice Decrements current supply (e.g. burn or correction). Only REWARDS_MANAGER_ROLE. function decreaseCurrentSupply(uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { - if (currentRewardSupply[_tokenId] >= _amount) { - currentRewardSupply[_tokenId] -= _amount; - } + currentRewardSupply[_tokenId] -= _amount; } + /// @notice Sets a user nonce as used/unused (replay protection for mint/claim). Only REWARDS_MANAGER_ROLE. function setUserNonce(address _user, uint256 _nonce, bool _used) external onlyRole(REWARDS_MANAGER_ROLE) { userNonces[_user][_nonce] = _used; } - function incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex) external onlyRole(REWARDS_MANAGER_ROLE) { - erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex]++; + /// @notice Advances the ERC721 distribution index for a reward slot by _delta (e.g. after distributing rewardAmount NFTs). Only REWARDS_MANAGER_ROLE. + function incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex, uint256 _delta) external onlyRole(REWARDS_MANAGER_ROLE) { + erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex] += _delta; } /*////////////////////////////////////////////////////////////// @@ -496,7 +552,6 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab //////////////////////////////////////////////////////////////*/ function _processERC20Token( - address, address tokenAddress, uint256 index, uint256[] memory totalBalances, @@ -529,7 +584,6 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab } function _processERC721Token( - address, address tokenAddress, uint256 index, uint256[] memory totalBalances, @@ -562,7 +616,6 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab } function _processERC1155Tokens( - address rewardsContract, uint256 erc1155Count, uint256 startIndex, address[] memory addresses, @@ -574,8 +627,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab string[] memory types, uint256[] memory tokenIds ) private view returns (uint256) { - IRewards rewards = IRewards(rewardsContract); - uint256[] memory ids = rewards.getAllItemIds(); + uint256[] memory ids = itemIds; address[] memory processedErc1155Addresses = new address[](erc1155Count); uint256[] memory processedErc1155TokenIds = new uint256[](erc1155Count); @@ -583,7 +635,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab uint256 currentIndex = startIndex; for (uint256 i = 0; i < ids.length; i++) { - LibItems.Reward[] memory rewardsList = rewards.getTokenRewards(ids[i]); + LibItems.Reward[] memory rewardsList = tokenRewards[ids[i]].rewards; for (uint256 j = 0; j < rewardsList.length; j++) { LibItems.Reward memory reward = rewardsList[j]; @@ -639,16 +691,24 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, UUPSUpgradeab return currentIndex; } - function _countUniqueErc1155TokenIds(address rewardsContract) private view returns (uint256) { - IRewards rewards = IRewards(rewardsContract); - uint256[] memory ids = rewards.getAllItemIds(); + 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[](ids.length * 10); - uint256[] memory uniqueTokenIds = new uint256[](ids.length * 10); + 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 = rewards.getTokenRewards(ids[i]); + LibItems.Reward[] memory rewardsList = tokenRewards[ids[i]].rewards; for (uint256 j = 0; j < rewardsList.length; j++) { if (rewardsList[j].rewardType == LibItems.RewardType.ERC1155) { diff --git a/scripts/deployRewardsManager.ts b/scripts/deployRewardsManager.ts index 30d0784..c535caf 100644 --- a/scripts/deployRewardsManager.ts +++ b/scripts/deployRewardsManager.ts @@ -6,7 +6,7 @@ import { ethers, upgrades } from 'hardhat'; * 1. Deploy RewardsServer implementation (for beacon) * 2. Deploy RewardsManager (UUPS proxy), initialize with (dev, manager) * 3. initializeBeacons(rewardsServerImpl) - * 4. Deploy RewardsFactory(manager), setBeacons, grant FACTORY_ROLE + * 4. Deploy RewardsFactory(manager), setBeacons, grant FACTORY_ROLE to factory on manager (required for deployServer to succeed) * * After this, anyone can call factory.deployServer(serverId) to create a server. * From aa0cde4d9f0aed9c6a3706a86c4b03fb1788a6e6 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 24 Feb 2026 11:38:32 -0300 Subject: [PATCH 3/9] Chore: Add tests cases --- .../soulbounds/RewardsManager.sol | 61 +-- .../upgradeables/soulbounds/RewardsServer.sol | 26 +- test/rewardsManager.test.ts | 346 ++++++++++++++++++ 3 files changed, 368 insertions(+), 65 deletions(-) diff --git a/contracts/upgradeables/soulbounds/RewardsManager.sol b/contracts/upgradeables/soulbounds/RewardsManager.sol index a746f75..7f2a5c8 100644 --- a/contracts/upgradeables/soulbounds/RewardsManager.sol +++ b/contracts/upgradeables/soulbounds/RewardsManager.sol @@ -54,7 +54,6 @@ contract RewardsManager is error BeaconNotInitialized(); error BeaconsAlreadyInitialized(); error InvalidSignature(); - error AlreadyUsedSignature(); error UnauthorizedServerAdmin(); error InsufficientBalance(); error InvalidAmount(); @@ -102,9 +101,6 @@ contract RewardsManager is // Beacons (single implementation per type, upgradeable for all servers) UpgradeableBeacon public treasuryBeacon; - // Global signature replay protection - mapping(bytes => bool) private usedSignatures; - // ETH reserved for pending claims (withdrawals cannot exceed balance - this) uint256 private ethReservedTotal; @@ -330,10 +326,6 @@ contract RewardsManager is uint256 nonce, bytes calldata signature ) internal view returns (address) { - if (usedSignatures[signature]) { - revert AlreadyUsedSignature(); - } - uint256 currentChainId = block.chainid; bytes32 message = keccak256( abi.encode( @@ -626,11 +618,12 @@ contract RewardsManager is uint256 newSupply = server.currentRewardSupply(tokenId) + amount; if (newSupply > server.getRewardToken(tokenId).maxSupply) revert ExceedMaxSupply(); - server.increaseCurrentSupply(tokenId, amount); for (uint256 i = 0; i < amount; i++) { _distributeReward(treasuryAddr, to, tokenId); } + + server.increaseCurrentSupply(tokenId, amount); } function _decodeClaimData( @@ -640,8 +633,8 @@ contract RewardsManager is abi.decode(data, (address, uint256, address, uint256, uint256[])); } - /// @notice Permissionless claim: beneficiary presents server signature and receives one unit of reward per tokenId. - /// @dev Caller must be beneficiary. Signature is burned (replay protection). tokenIds length capped by MAX_CLAIM_TOKEN_IDS. + /// @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). tokenIds length capped by MAX_CLAIM_TOKEN_IDS. /// @param serverId Server id. /// @param data ABI-encoded (contractAddress, chainId, beneficiary, expiration, tokenIds). /// @param nonce User nonce (must not be used before). @@ -661,7 +654,6 @@ contract RewardsManager is ) = _decodeClaimData(data); if (contractAddress != address(this) || chainId != block.chainid) revert InvalidInput(); - if (msg.sender != beneficiary) revert InvalidInput(); if (tokenIds.length > MAX_CLAIM_TOKEN_IDS) revert InvalidInput(); Server storage s = _getServer(serverId); @@ -669,7 +661,6 @@ contract RewardsManager is if (serverContract.userNonces(beneficiary, nonce)) revert NonceAlreadyUsed(); _verifyServerSignature(serverId, beneficiary, expiration, tokenIds, nonce, signature); - usedSignatures[signature] = true; serverContract.setUserNonce(beneficiary, nonce, true); for (uint256 i = 0; i < tokenIds.length; i++) { @@ -800,11 +791,7 @@ contract RewardsManager is address token ) external view returns (uint256) { Server storage s = _getServer(serverId); - return - RewardsServer(s.treasury).getTreasuryBalance( - s.treasury, - token - ); + return RewardsServer(s.treasury).getTreasuryBalance(token); } /// @notice Returns reserved amount for token on the server. @@ -813,11 +800,7 @@ contract RewardsManager is address token ) external view returns (uint256) { Server storage s = _getServer(serverId); - return - RewardsServer(s.treasury).getReservedAmount( - s.treasury, - token - ); + return RewardsServer(s.treasury).getReservedAmount(token); } /// @notice Returns unreserved (available) treasury balance for token on the server. @@ -826,11 +809,7 @@ contract RewardsManager is address token ) external view returns (uint256) { Server storage s = _getServer(serverId); - return - RewardsServer(s.treasury).getAvailableTreasuryBalance( - s.treasury, - token - ); + return RewardsServer(s.treasury).getAvailableTreasuryBalance(token); } /// @notice Returns whitelisted token addresses for the server. @@ -838,10 +817,7 @@ contract RewardsManager is bytes32 serverId ) external view returns (address[] memory) { Server storage s = _getServer(serverId); - return - RewardsServer(s.treasury).getWhitelistedTokens( - s.treasury - ); + return RewardsServer(s.treasury).getWhitelistedTokens(); } /// @notice Returns whether token is whitelisted on the server. @@ -850,11 +826,7 @@ contract RewardsManager is address token ) external view returns (bool) { Server storage s = _getServer(serverId); - return - RewardsServer(s.treasury).isWhitelistedToken( - s.treasury, - token - ); + return RewardsServer(s.treasury).isWhitelistedToken(token); } /// @notice Returns whether the reward token exists on the server. @@ -899,21 +871,6 @@ contract RewardsManager is } } - /// @notice Returns true if the reward token exists, claim is not paused, and there is remaining supply to claim. - function canUserClaim( - bytes32 serverId, - address, - uint256 tokenId - ) external view returns (bool) { - Server storage s = _getServer(serverId); - RewardsServer serverContract = RewardsServer(s.treasury); - if (!serverContract.isTokenExists(tokenId)) return false; - if (serverContract.isClaimRewardPaused(tokenId)) return false; - uint256 maxSupply = serverContract.getRewardToken(tokenId).maxSupply; - uint256 current = serverContract.currentRewardSupply(tokenId); - return current < maxSupply; - } - /// @notice Returns remaining claimable supply for a reward token (maxSupply - currentSupply), or 0 if exhausted/nonexistent. function getRemainingSupply( bytes32 serverId, diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol index b443d78..c63f3e4 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -372,43 +372,43 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU return (addresses, totalBalances, reservedBalances, availableBalances, symbols, names, types, tokenIds); } - /// @notice ERC20 balance of this contract for _token (IRewards-compatible signature; first param ignored). - function getTreasuryBalance(address, address _token) external view returns (uint256) { + /// @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 (IRewards-compatible signature; first param ignored). - function getReservedAmount(address, address _token) external view returns (uint256) { + /// @notice Reserved amount for _token. + function getReservedAmount(address _token) external view returns (uint256) { return reservedAmounts[_token]; } - /// @notice Unreserved ERC20 balance for _token (IRewards-compatible signature; first param ignored). - function getAvailableTreasuryBalance(address, address _token) external view returns (uint256) { + /// @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 (IRewards-compatible; first param ignored). - function getWhitelistedTokens(address) external view returns (address[] memory) { + /// @notice List of whitelisted token addresses. + function getWhitelistedTokens() external view returns (address[] memory) { return whitelistedTokenList; } - /// @notice Whether _token is whitelisted (IRewards-compatible; first param ignored). - function isWhitelistedToken(address, address _token) external view returns (bool) { + /// @notice Whether _token is whitelisted. + function isWhitelistedToken(address _token) external view returns (bool) { return whitelistedTokens[_token]; } /*////////////////////////////////////////////////////////////// - REWARD RESERVE & VIEW (for IRewards) + REWARD RESERVE & VIEW //////////////////////////////////////////////////////////////*/ - /// @notice All reward token ids (item ids) defined on this server. IRewards interface. + /// @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. IRewards interface. + /// @notice Reward definitions for a given reward token id. function getTokenRewards(uint256 _tokenId) external view returns (LibItems.Reward[] memory) { return tokenRewards[_tokenId].rewards; } diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts index 0d05283..1c04737 100644 --- a/test/rewardsManager.test.ts +++ b/test/rewardsManager.test.ts @@ -61,6 +61,54 @@ describe('RewardsManager', function () { }); }); + describe('RewardsFactory ownership', function () { + it('owner can call transferOwnership and pendingOwner is set', async function () { + const { factory, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + expect(await factory.owner()).to.equal(devWallet.address); + await factory.connect(devWallet).transferOwnership(managerWallet.address); + expect(await factory.pendingOwner()).to.equal(managerWallet.address); + expect(await factory.owner()).to.equal(devWallet.address); + }); + + it('non-owner cannot call transferOwnership or setBeacons', async function () { + const { manager, factory, user1 } = await loadFixture(deployRewardsManagerFixture); + await expect( + factory.connect(user1).transferOwnership(user1.address) + ).to.be.revertedWithCustomError(factory, 'Unauthorized'); + const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); + const newFactory = await RewardsFactory.deploy(await manager.getAddress()); + await newFactory.waitForDeployment(); + await expect( + newFactory.connect(user1).setBeacons(await factory.treasuryBeacon()) + ).to.be.revertedWithCustomError(newFactory, 'Unauthorized'); + }); + + it('only pendingOwner can call acceptOwnership; owner and pendingOwner updated', async function () { + const { factory, devWallet, managerWallet, user1 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(devWallet).transferOwnership(managerWallet.address); + await expect(factory.connect(user1).acceptOwnership()).to.be.revertedWithCustomError(factory, 'Unauthorized'); + await factory.connect(managerWallet).acceptOwnership(); + expect(await factory.owner()).to.equal(managerWallet.address); + expect(await factory.pendingOwner()).to.equal(ethers.ZeroAddress); + }); + + it('after transfer old owner cannot call setBeacons', async function () { + const { manager, factory, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + const beaconAddr = await factory.treasuryBeacon(); + const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); + const factory2 = await RewardsFactory.deploy(await manager.getAddress()); + await factory2.waitForDeployment(); + expect(await factory2.owner()).to.equal(devWallet.address); + await factory2.connect(devWallet).transferOwnership(managerWallet.address); + await factory2.connect(managerWallet).acceptOwnership(); + expect(await factory2.owner()).to.equal(managerWallet.address); + await expect(factory2.connect(devWallet).setBeacons(beaconAddr)).to.be.revertedWithCustomError( + factory2, + 'Unauthorized' + ); + }); + }); + describe('server admin and signers', function () { it('server admin can set signer and withdrawer', async function () { const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); @@ -183,6 +231,99 @@ describe('RewardsManager', function () { 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, 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 manager + .connect(managerWallet) + .createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: ethRequired }); + const expiration = Math.floor(Date.now() / 1000) + 3600; + const { data, signature } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 0 + ); + const before = await ethers.provider.getBalance(user1.address); + const tx = await manager.connect(user1).claim(SERVER_ID, data, 0, 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('MANAGER_ROLE can withdraw unreserved ETHER from manager', async function () { + const { manager, 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 manager + .connect(managerWallet) + .createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: ethers.parseEther('1') }); + const expiration = Math.floor(Date.now() / 1000) + 3600; + const { data, signature } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 0 + ); + await manager.connect(user1).claim(SERVER_ID, data, 0, signature); + const extraEth = ethers.parseEther('0.3'); + await managerWallet.sendTransaction({ + to: await manager.getAddress(), + value: extraEth, + }); + const before = await ethers.provider.getBalance(user1.address); + await manager + .connect(managerWallet) + .withdrawAssets( + SERVER_ID, + 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, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 1; @@ -226,5 +367,210 @@ describe('RewardsManager', function () { 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, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + const expiration = Math.floor(Date.now() / 1000) + 3600; + const { data, signature } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 0 + ); + const before = await mockERC20.balanceOf(user1.address); + await manager.connect(user2).claim(SERVER_ID, data, 0, 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, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC721 = await ethers.getContractFactory('MockERC721'); + const mockERC721 = await MockERC721.deploy(); + await mockERC721.waitForDeployment(); + const serverAddr = await manager.getServer(SERVER_ID); + await manager.connect(managerWallet).whitelistToken(SERVER_ID, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + const expiration = Math.floor(Date.now() / 1000) + 3600; + const { data: data0, signature: sig0 } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 0 + ); + await manager.connect(user1).claim(SERVER_ID, data0, 0, sig0); + expect(await mockERC721.ownerOf(0)).to.equal(user1.address); + const { data: data1, signature: sig1 } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [tokenId], + expiration, + 1 + ); + await manager.connect(user1).claim(SERVER_ID, data1, 1, 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, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const MockERC1155 = await ethers.getContractFactory('MockERC1155'); + const mockERC1155 = await MockERC1155.deploy(); + await mockERC1155.waitForDeployment(); + const serverAddr = await manager.getServer(SERVER_ID); + await manager.connect(managerWallet).whitelistToken(SERVER_ID, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + const expiration = Math.floor(Date.now() / 1000) + 3600; + const { data, signature } = await buildClaimDataAndSignature( + manager, + SERVER_ID, + managerWallet, + user1.address, + [rewardTokenId], + expiration, + 0 + ); + await manager.connect(user1).claim(SERVER_ID, data, 0, signature); + expect(await mockERC1155.balanceOf(user1.address, erc1155TokenId)).to.equal(10); + }); + }); + }); + + describe('access control', function () { + it('non-MANAGER cannot call whitelistToken', async function () { + const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(user1).deployServer(SERVER_ID); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('M', 'M'); + await mockERC20.waitForDeployment(); + await expect( + manager.connect(user2).whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1) + ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + }); + + it('non-MANAGER cannot call createTokenAndDepositRewards', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.factory.connect(base.user1).deployServer(SERVER_ID); + const MockERC20 = await ethers.getContractFactory('MockERC20'); + const mockERC20 = await MockERC20.deploy('M', 'M'); + await mockERC20.waitForDeployment(); + await base.manager.connect(base.managerWallet).whitelistToken(SERVER_ID, 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( + base.manager.connect(base.user1).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }) + ).to.be.revertedWithCustomError(base.manager, 'AccessControlUnauthorizedAccount'); + }); + + it('non-MANAGER cannot call withdrawAssets', async function () { + const base = await loadFixture(deployRewardsManagerFixture); + await base.factory.connect(base.user1).deployServer(SERVER_ID); + await expect( + base.manager + .connect(base.user1) + .withdrawAssets(SERVER_ID, 1, base.user2.address, ethers.ZeroAddress, [], []) + ).to.be.revertedWithCustomError(base.manager, 'AccessControlUnauthorizedAccount'); + }); + + it('non-MANAGER cannot call pause', async function () { + const { manager, user1 } = await loadFixture(deployRewardsManagerFixture); + await expect(manager.connect(user1).pause()).to.be.revertedWithCustomError( + manager, + 'AccessControlUnauthorizedAccount' + ); + }); + + it('only FACTORY_ROLE can call registerServer', async function () { + const { manager, user1 } = await loadFixture(deployRewardsManagerFixture); + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const impl = await RewardsServerImpl.deploy(); + await impl.waitForDeployment(); + const serverId2 = ethers.keccak256(ethers.toUtf8Bytes('server-2')); + await expect( + manager.connect(user1).registerServer(serverId2, await impl.getAddress()) + ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + }); }); }); From 2ed481f04b62b5875239e3ba39f262b0500fbe2e Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 24 Feb 2026 12:59:49 -0300 Subject: [PATCH 4/9] Feat: Add missing actions --- .../soulbounds/RewardsManager.sol | 53 +++++ .../upgradeables/soulbounds/RewardsServer.sol | 72 ++++++- test/rewardsManager.test.ts | 193 +++++++++++++++--- 3 files changed, 292 insertions(+), 26 deletions(-) diff --git a/contracts/upgradeables/soulbounds/RewardsManager.sol b/contracts/upgradeables/soulbounds/RewardsManager.sol index 7f2a5c8..a46b8c9 100644 --- a/contracts/upgradeables/soulbounds/RewardsManager.sol +++ b/contracts/upgradeables/soulbounds/RewardsManager.sol @@ -285,12 +285,32 @@ contract RewardsManager is emit ServerSignerUpdated(serverId, signer, isActive); } + /// @notice Adds a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. + function addWhitelistSigner(bytes32 serverId, address signer) external { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).setSignerAllowedBy(msg.sender, signer, true); + emit ServerSignerUpdated(serverId, signer, true); + } + + /// @notice Removes a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. + function removeWhitelistSigner(bytes32 serverId, address signer) external { + Server storage s = _getServer(serverId); + RewardsServer(s.treasury).setSignerAllowedBy(msg.sender, signer, false); + emit ServerSignerUpdated(serverId, signer, false); + } + /// @notice Returns whether the address is an active signer for the server. function isServerSigner(bytes32 serverId, address signer) external view returns (bool) { Server storage s = _getServer(serverId); return RewardsServer(s.treasury).isSigner(signer); } + /// @notice Returns list of all active signer addresses for the server (rewards-get-whitelist-signers). + function getServerSigners(bytes32 serverId) external view returns (address[] memory) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).getSigners(); + } + /// @notice Enables or disables a withdrawer for the server. Caller must be SERVER_ADMIN_ROLE on the server. function setServerWithdrawer( bytes32 serverId, @@ -470,6 +490,19 @@ contract RewardsManager is emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply + additionalSupply); } + /// @notice Reduces max supply of a reward token on the server. Only MANAGER_ROLE. For admin/DB parity. + function reduceRewardSupply( + bytes32 serverId, + uint256 tokenId, + uint256 reduceBy + ) external onlyRole(MANAGER_ROLE) { + Server storage s = _getServer(serverId); + RewardsServer serverContract = RewardsServer(s.treasury); + uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; + serverContract.reduceRewardSupply(tokenId, reduceBy); + emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply - reduceBy); + } + /// @notice Updates the token URI for a reward token. Only MANAGER_ROLE. function updateTokenUri( bytes32 serverId, @@ -626,6 +659,14 @@ contract RewardsManager is server.increaseCurrentSupply(tokenId, amount); } + /// @notice Decodes claim data for debugging. Same encoding as used in claim(serverId, data, nonce, signature). + function decodeData( + bytes calldata data + ) external pure returns (address contractAddress, uint256 chainId, address beneficiary, uint256 expiration, uint256[] memory tokenIds) { + (contractAddress, chainId, beneficiary, expiration, tokenIds) = + abi.decode(data, (address, uint256, address, uint256, uint256[])); + } + function _decodeClaimData( bytes calldata data ) private pure returns (address contractAddress, uint256 chainId, address beneficiary, uint256 expiration, uint256[] memory tokenIds) { @@ -835,6 +876,18 @@ contract RewardsManager is return RewardsServer(s.treasury).isTokenExists(tokenId); } + /// @notice Returns whether minting is paused for the reward token on the server. + function isTokenMintPaused(bytes32 serverId, uint256 tokenId) external view returns (bool) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).isTokenMintPaused(tokenId); + } + + /// @notice Returns whether claiming is paused for the reward token on the server. + function isClaimRewardPaused(bytes32 serverId, uint256 tokenId) external view returns (bool) { + Server storage s = _getServer(serverId); + return RewardsServer(s.treasury).isClaimRewardPaused(tokenId); + } + /// @notice Returns structured reward token details (URI, maxSupply, reward types/amounts/addresses/tokenIds). function getTokenDetails( bytes32 serverId, diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol index c63f3e4..9b2775d 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -62,6 +62,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU // Server-level access (admin, signers for claim, withdrawers) mapping(address => bool) public signers; + address[] private signerList; mapping(address => bool) public withdrawers; // Whitelist @@ -88,7 +89,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU // Per-user nonce (for mint/claim signatures) mapping(address => mapping(uint256 => bool)) public userNonces; - uint256[33] private __gap; + uint256[32] private __gap; /*////////////////////////////////////////////////////////////// EVENTS @@ -133,7 +134,17 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU /// @param active True to allow signing, false to revoke. function setSigner(address account, bool active) external onlyRole(SERVER_ADMIN_ROLE) { if (account == address(0)) revert AddressIsZero(); - signers[account] = active; + if (active) { + if (!signers[account]) { + signers[account] = true; + signerList.push(account); + } + } else { + if (signers[account]) { + signers[account] = false; + _removeFromSignerList(account); + } + } emit SignerUpdated(account, active); } @@ -161,6 +172,11 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU return signers[account]; } + /// @notice Returns list of all active signer addresses (for rewards-get-whitelist-signers). + function getSigners() external view returns (address[] memory) { + return signerList; + } + /// @notice Returns whether the account is an active withdrawer. function isWithdrawer(address account) external view returns (bool) { return withdrawers[account]; @@ -170,10 +186,30 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU function setSignerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); if (account == address(0)) revert AddressIsZero(); - signers[account] = active; + if (active) { + if (!signers[account]) { + signers[account] = true; + signerList.push(account); + } + } else { + if (signers[account]) { + signers[account] = false; + _removeFromSignerList(account); + } + } emit SignerUpdated(account, active); } + function _removeFromSignerList(address account) private { + for (uint256 i = 0; i < signerList.length; i++) { + if (signerList[i] == account) { + signerList[i] = signerList[signerList.length - 1]; + signerList.pop(); + return; + } + } + } + /// @notice Same as setWithdrawer but called by RewardsManager; caller must be SERVER_ADMIN_ROLE. function setWithdrawerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); @@ -517,6 +553,36 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU tokenRewards[_tokenId] = rewardToken; } + /// @notice Reduces max supply for a reward token; releases proportional ERC20/ERC1155 reservations. Only REWARDS_MANAGER_ROLE. + /// @dev New max supply must not be below currentRewardSupply. ERC721: only maxSupply is reduced (reserved NFT ids unchanged). + /// @param _tokenId Reward token id. + /// @param _reduceBy Amount to subtract from max supply (must be > 0). + function reduceRewardSupply(uint256 _tokenId, uint256 _reduceBy) external onlyRole(REWARDS_MANAGER_ROLE) { + if (!tokenExists[_tokenId]) revert TokenNotExist(); + if (_reduceBy == 0) revert InvalidAmount(); + + LibItems.RewardToken memory rewardToken = tokenRewards[_tokenId]; + uint256 current = currentRewardSupply[_tokenId]; + uint256 newSupply = rewardToken.maxSupply - _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.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; + } + // ERC721: no reserved amount to release; maxSupply reduction only + } + + rewardToken.maxSupply = newSupply; + tokenRewards[_tokenId] = rewardToken; + } + /// @notice Pauses or unpauses minting for a reward token. Only REWARDS_MANAGER_ROLE. function setTokenMintPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_MANAGER_ROLE) { isTokenMintPaused[_tokenId] = _isPaused; diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts index 1c04737..4eef44c 100644 --- a/test/rewardsManager.test.ts +++ b/test/rewardsManager.test.ts @@ -38,6 +38,29 @@ describe('RewardsManager', function () { }; } + async function deployWithServerAndTokenFixture() { + const base = await loadFixture(deployRewardsManagerFixture); + await base.factory.connect(base.user1).deployServer(SERVER_ID); + await base.manager.connect(base.user1).setServerSigner(SERVER_ID, 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 base.manager + .connect(base.managerWallet) + .whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1); // ERC20 + await mockERC20 + .connect(base.managerWallet) + .approve(await base.manager.getServer(SERVER_ID), ethers.parseEther('1000')); + await base.manager + .connect(base.managerWallet) + .depositToTreasury(SERVER_ID, await mockERC20.getAddress(), ethers.parseEther('1000')); + + return { ...base, mockERC20 }; + } + describe('deployServer', function () { it('should deploy a server with RewardsServer', async function () { const { manager, factory, user1 } = await loadFixture(deployRewardsManagerFixture); @@ -159,29 +182,6 @@ describe('RewardsManager', function () { } describe('treasury and reward flow', function () { - async function deployWithServerAndTokenFixture() { - const base = await loadFixture(deployRewardsManagerFixture); - await base.factory.connect(base.user1).deployServer(SERVER_ID); - await base.manager.connect(base.user1).setServerSigner(SERVER_ID, 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 base.manager - .connect(base.managerWallet) - .whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1); // ERC20 - await mockERC20 - .connect(base.managerWallet) - .approve(await base.manager.getServer(SERVER_ID), ethers.parseEther('1000')); - await base.manager - .connect(base.managerWallet) - .depositToTreasury(SERVER_ID, await mockERC20.getAddress(), ethers.parseEther('1000')); - - return { ...base, mockERC20 }; - } - it('should whitelist token and deposit to server treasury', async function () { const { manager, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); const balance = await manager.getServerTreasuryBalance( @@ -573,4 +573,151 @@ describe('RewardsManager', function () { ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); }); }); + + describe('admin proxy and wrapper functions', function () { + describe('pause views (isTokenMintPaused, isClaimRewardPaused)', function () { + it('isTokenMintPaused and isClaimRewardPaused reflect server state', async function () { + const { manager, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + expect(await manager.isTokenMintPaused(SERVER_ID, tokenId)).to.be.false; + expect(await manager.isClaimRewardPaused(SERVER_ID, tokenId)).to.be.false; + await manager.connect(managerWallet).updateTokenMintPaused(SERVER_ID, tokenId, true); + expect(await manager.isTokenMintPaused(SERVER_ID, tokenId)).to.be.true; + await manager.connect(managerWallet).updateClaimRewardPaused(SERVER_ID, tokenId, true); + expect(await manager.isClaimRewardPaused(SERVER_ID, tokenId)).to.be.true; + }); + }); + + describe('decodeData', function () { + it('decodes claim data for debugging', async function () { + const { manager } = await loadFixture(deployRewardsManagerFixture); + const chainId = (await ethers.provider.getNetwork()).chainId; + const managerAddress = await manager.getAddress(); + const beneficiary = '0x0000000000000000000000000000000000000001'; + const expiration = 999999; + const tokenIds = [1, 2]; + const data = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'uint256', 'address', 'uint256', 'uint256[]'], + [managerAddress, chainId, beneficiary, expiration, tokenIds] + ); + const decoded = await manager.decodeData(data); + expect(decoded.contractAddress).to.equal(managerAddress); + expect(decoded.chainId).to.equal(chainId); + expect(decoded.beneficiary).to.equal(beneficiary); + expect(decoded.expiration).to.equal(expiration); + expect(decoded.tokenIds.length).to.equal(2); + expect(decoded.tokenIds[0]).to.equal(1); + expect(decoded.tokenIds[1]).to.equal(2); + }); + }); + + describe('getServerSigners', function () { + it('returns empty when no signers added', async function () { + const { manager, factory } = await loadFixture(deployRewardsManagerFixture); + await factory.connect((await ethers.getSigners())[2]).deployServer(SERVER_ID); + const list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(0); + }); + + it('returns signers after addWhitelistSigner', async function () { + const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(user1).deployServer(SERVER_ID); + let list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(0); + await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); + list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(1); + expect(list[0]).to.equal(user2.address); + await manager.connect(user1).addWhitelistSigner(SERVER_ID, user1.address); + 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 manager.connect(user1).removeWhitelistSigner(SERVER_ID, user2.address); + list = await manager.getServerSigners(SERVER_ID); + expect(list.length).to.equal(1); + expect(list[0]).to.equal(user1.address); + }); + }); + + describe('addWhitelistSigner and removeWhitelistSigner', function () { + it('addWhitelistSigner enables signer, removeWhitelistSigner disables', async function () { + const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await factory.connect(user1).deployServer(SERVER_ID); + expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.false; + await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); + expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.true; + await manager.connect(user1).removeWhitelistSigner(SERVER_ID, user2.address); + expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.false; + }); + }); + + describe('reduceRewardSupply', function () { + it('MANAGER_ROLE can reduce reward supply and event is emitted', async function () { + const { manager, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + let details = await manager.getTokenDetails(SERVER_ID, tokenId); + expect(details.maxSupply).to.equal(10); + await expect(manager.connect(managerWallet).reduceRewardSupply(SERVER_ID, tokenId, 3)) + .to.emit(manager, 'RewardSupplyChanged') + .withArgs(SERVER_ID, tokenId, 10, 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-MANAGER cannot call reduceRewardSupply', async function () { + const { manager, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + await expect( + manager.connect(user1).reduceRewardSupply(SERVER_ID, tokenId, 2) + ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + }); + }); + }); }); From bd7acbd46bca7530c8fd9b8d051eef0a0b9cb40b Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Tue, 24 Feb 2026 23:48:49 -0300 Subject: [PATCH 5/9] Feat: Polishing --- .../soulbounds/RewardsFactory.sol | 96 -- .../soulbounds/RewardsManager.sol | 953 ------------------ .../upgradeables/soulbounds/RewardsRouter.sol | 717 +++++++++++++ .../upgradeables/soulbounds/RewardsServer.sol | 378 ++++--- scripts/deployRewardsManager.ts | 50 +- test/rewardsManager.test.ts | 228 ++--- 6 files changed, 1083 insertions(+), 1339 deletions(-) delete mode 100644 contracts/upgradeables/soulbounds/RewardsFactory.sol delete mode 100644 contracts/upgradeables/soulbounds/RewardsManager.sol create mode 100644 contracts/upgradeables/soulbounds/RewardsRouter.sol diff --git a/contracts/upgradeables/soulbounds/RewardsFactory.sol b/contracts/upgradeables/soulbounds/RewardsFactory.sol deleted file mode 100644 index def6c4c..0000000 --- a/contracts/upgradeables/soulbounds/RewardsFactory.sol +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.28; - -// @author Summon.xyz Team - https://summon.xyz - -import { UpgradeableBeacon } from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; -import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; - -import { RewardsServer } from "./RewardsServer.sol"; - -/** - * @title RewardsFactory - * @notice Deploys per-server RewardsServer (BeaconProxy) and registers each server with RewardsManager. - * @dev Caller of deployServer becomes the server admin. Requires FACTORY_ROLE on the manager to be granted to this contract. - */ -interface IRewardsManagerFactory { - function registerServer(bytes32 serverId, address treasury) external; -} - -contract RewardsFactory { - error AddressIsZero(); - error Unauthorized(); - error BeaconNotSet(); - error BeaconsAlreadySet(); - - address public immutable manager; - address public owner; - address public pendingOwner; - - UpgradeableBeacon public treasuryBeacon; - - event BeaconsSet(address treasuryBeacon); - event ServerDeployed(bytes32 indexed serverId, address indexed serverAdmin); - event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); - event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); - - constructor(address _manager) { - if (_manager == address(0)) revert AddressIsZero(); - manager = _manager; - owner = msg.sender; - } - - modifier onlyOwner() { - if (msg.sender != owner) revert Unauthorized(); - _; - } - - /// @notice Sets the UpgradeableBeacon used for RewardsServer implementations. Callable only once by owner. - /// @param _treasuryBeacon Address of the deployed RewardsServer beacon. - function setBeacons(address _treasuryBeacon) external onlyOwner { - if (_treasuryBeacon == address(0)) revert AddressIsZero(); - if (address(treasuryBeacon) != address(0)) revert BeaconsAlreadySet(); - treasuryBeacon = UpgradeableBeacon(_treasuryBeacon); - emit BeaconsSet(_treasuryBeacon); - } - - /// @notice Starts a two-step ownership transfer. New owner must call acceptOwnership. - /// @param newOwner Address that will be able to call acceptOwnership. - function transferOwnership(address newOwner) external onlyOwner { - if (newOwner == address(0)) revert AddressIsZero(); - pendingOwner = newOwner; - emit OwnershipTransferStarted(owner, newOwner); - } - - /// @notice Completes ownership transfer. Callable only by the address set via transferOwnership. - function acceptOwnership() external { - if (msg.sender != pendingOwner) revert Unauthorized(); - address previousOwner = owner; - owner = pendingOwner; - delete pendingOwner; - emit OwnershipTransferred(previousOwner, owner); - } - - /// @notice Deploys a new RewardsServer (BeaconProxy) for the given server and registers it with the RewardsManager. - /// @dev Caller becomes the server admin. Requires FACTORY_ROLE on the manager to be granted to this contract. - /// @param serverId Unique identifier for the server (e.g. keccak256 of a string id). - function deployServer(bytes32 serverId) external { - if (address(treasuryBeacon) == address(0)) revert BeaconNotSet(); - - address serverAdmin = msg.sender; - - bytes memory treasuryInitData = abi.encodeWithSelector( - RewardsServer.initialize.selector, - manager, - manager, - serverAdmin - ); - address treasuryProxy = address( - new BeaconProxy(address(treasuryBeacon), treasuryInitData) - ); - - IRewardsManagerFactory(manager).registerServer(serverId, treasuryProxy); - - emit ServerDeployed(serverId, serverAdmin); - } -} diff --git a/contracts/upgradeables/soulbounds/RewardsManager.sol b/contracts/upgradeables/soulbounds/RewardsManager.sol deleted file mode 100644 index a46b8c9..0000000 --- a/contracts/upgradeables/soulbounds/RewardsManager.sol +++ /dev/null @@ -1,953 +0,0 @@ -// 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 { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -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 { - MessageHashUtils -} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; -import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; - -import { LibItems } from "../../libraries/LibItems.sol"; -import { RewardsServer } from "./RewardsServer.sol"; - -/** - * @title RewardsManager - * @notice Multitenant rewards manager: per-tenant RewardsServer; permissionless claim via server signature. - * No AccessToken: users claim rewards directly with a signed message. - */ -contract RewardsManager is - Initializable, - AccessControlUpgradeable, - PausableUpgradeable, - ReentrancyGuardUpgradeable, - UUPSUpgradeable -{ - using SafeERC20 for IERC20; - - /*////////////////////////////////////////////////////////////// - ERRORS - //////////////////////////////////////////////////////////////*/ - error AddressIsZero(); - error ServerAlreadyExists(); - error ServerDoesNotExist(); - error InvalidServerId(); - error BeaconNotInitialized(); - error BeaconsAlreadyInitialized(); - error InvalidSignature(); - error UnauthorizedServerAdmin(); - error InsufficientBalance(); - error InvalidAmount(); - error InvalidInput(); - error NonceAlreadyUsed(); - error TokenNotExist(); - error ExceedMaxSupply(); - error MintPaused(); - error ClaimRewardPaused(); - error DupTokenId(); - error TokenNotWhitelisted(); - error InsufficientTreasuryBalance(); - error TransferFailed(); - error InvalidLength(); - - /*////////////////////////////////////////////////////////////// - CONSTANTS - //////////////////////////////////////////////////////////////*/ - uint256 public constant MAX_CLAIM_TOKEN_IDS = 50; - - bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE"); - bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); - bytes32 public constant DEV_CONFIG_ROLE = keccak256("DEV_CONFIG_ROLE"); - bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); - bytes32 public constant FACTORY_ROLE = keccak256("FACTORY_ROLE"); - bytes32 private constant REWARDS_MANAGER_ROLE = - keccak256("REWARDS_MANAGER_ROLE"); - - /*////////////////////////////////////////////////////////////// - STRUCTS - //////////////////////////////////////////////////////////////*/ - - struct Server { - address treasury; - bool exists; - } - - /*////////////////////////////////////////////////////////////// - STATE - //////////////////////////////////////////////////////////////*/ - - // Per-server registry (one manager has many servers) - mapping(bytes32 => Server) private servers; - - // Beacons (single implementation per type, upgradeable for all servers) - UpgradeableBeacon public treasuryBeacon; - - // ETH reserved for pending claims (withdrawals cannot exceed balance - this) - uint256 private ethReservedTotal; - - uint256[44] private __gap; - - /*////////////////////////////////////////////////////////////// - EVENTS - //////////////////////////////////////////////////////////////*/ - - event ServerDeployed(bytes32 indexed serverId, address treasury); - - event ServerSignerUpdated( - bytes32 indexed serverId, - address indexed signer, - bool isActive - ); - - event ServerWithdrawerUpdated( - bytes32 indexed serverId, - address indexed account, - bool isActive - ); - - event ServerAdminTransferred( - bytes32 indexed serverId, - address indexed oldAdmin, - address indexed newAdmin - ); - - event TokenAdded(bytes32 indexed serverId, uint256 indexed tokenId); - event Minted( - bytes32 indexed serverId, - address indexed to, - uint256 indexed tokenId, - uint256 amount, - bool soulbound - ); - event Claimed( - bytes32 indexed serverId, - address indexed to, - uint256 indexed tokenId, - uint256 amount - ); - event TokenMintPausedUpdated( - bytes32 indexed serverId, - uint256 indexed tokenId, - bool isPaused - ); - event ClaimRewardPausedUpdated( - bytes32 indexed serverId, - uint256 indexed tokenId, - bool isPaused - ); - event RewardSupplyChanged( - bytes32 indexed serverId, - uint256 indexed tokenId, - uint256 oldSupply, - uint256 newSupply - ); - event TokenURIChanged( - bytes32 indexed serverId, - uint256 indexed tokenId, - string newUri - ); - event AssetsWithdrawn( - bytes32 indexed serverId, - LibItems.RewardType rewardType, - address indexed to, - uint256 amount - ); - - /*////////////////////////////////////////////////////////////// - INITIALIZER - //////////////////////////////////////////////////////////////*/ - - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - _disableInitializers(); - } - - /// @notice Initializes roles (dev, manager, upgrader). Called once by the proxy. - /// @param _devWallet Receives DEFAULT_ADMIN_ROLE, DEV_CONFIG_ROLE, UPGRADER_ROLE. - /// @param _managerWallet Receives MANAGER_ROLE, MINTER_ROLE. - function initialize( - address _devWallet, - address _managerWallet - ) external initializer { - if ( - _devWallet == address(0) || - _managerWallet == 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, _managerWallet); - _grantRole(MINTER_ROLE, _managerWallet); - } - - function _authorizeUpgrade( - address newImplementation - ) internal override onlyRole(UPGRADER_ROLE) {} - - /*////////////////////////////////////////////////////////////// - BEACON CONFIGURATION - //////////////////////////////////////////////////////////////*/ - - /// @notice Sets the RewardsServer implementation beacon. Callable once by DEV_CONFIG_ROLE. - /// @param _treasuryImplementation Implementation contract for RewardsServer (BeaconProxy targets this). - function initializeBeacons(address _treasuryImplementation) external onlyRole(DEV_CONFIG_ROLE) { - if (address(_treasuryImplementation) == address(0)) { - revert AddressIsZero(); - } - if (address(treasuryBeacon) != address(0)) { - revert BeaconsAlreadyInitialized(); - } - - treasuryBeacon = new UpgradeableBeacon( - _treasuryImplementation, - address(this) - ); - } - - /*////////////////////////////////////////////////////////////// - SERVER MANAGEMENT - //////////////////////////////////////////////////////////////*/ - - function _getServer( - bytes32 serverId - ) internal view returns (Server storage s) { - s = servers[serverId]; - if (!s.exists) { - revert ServerDoesNotExist(); - } - } - - /// @notice Registers a new server (RewardsServer proxy) for the given serverId. Only FACTORY_ROLE (RewardsFactory). - /// @param serverId Unique server identifier. - /// @param treasury Address of the deployed RewardsServer proxy. - function registerServer(bytes32 serverId, address treasury) external onlyRole(FACTORY_ROLE) { - if (serverId == bytes32(0)) revert InvalidServerId(); - if (servers[serverId].exists) revert ServerAlreadyExists(); - if (treasury == address(0)) revert AddressIsZero(); - - servers[serverId] = Server({ treasury: treasury, exists: true }); - - emit ServerDeployed(serverId, treasury); - } - - /// @notice Transfers server admin to newAdmin. Caller must be current SERVER_ADMIN_ROLE on the server. - function transferServerAdmin(bytes32 serverId, address newAdmin) external { - if (newAdmin == address(0)) revert AddressIsZero(); - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).transferServerAdminAllowedBy(msg.sender, newAdmin); - emit ServerAdminTransferred(serverId, msg.sender, newAdmin); - } - - /// @notice Returns the RewardsServer (treasury) address for a server. - function getServer(bytes32 serverId) external view returns (address treasury) { - Server storage s = _getServer(serverId); - return s.treasury; - } - - /*////////////////////////////////////////////////////////////// - PER-SERVER ACCESS CONTROL - //////////////////////////////////////////////////////////////*/ - - /// @notice Enables or disables a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. - function setServerSigner( - bytes32 serverId, - address signer, - bool isActive - ) external { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).setSignerAllowedBy(msg.sender, signer, isActive); - emit ServerSignerUpdated(serverId, signer, isActive); - } - - /// @notice Adds a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. - function addWhitelistSigner(bytes32 serverId, address signer) external { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).setSignerAllowedBy(msg.sender, signer, true); - emit ServerSignerUpdated(serverId, signer, true); - } - - /// @notice Removes a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. - function removeWhitelistSigner(bytes32 serverId, address signer) external { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).setSignerAllowedBy(msg.sender, signer, false); - emit ServerSignerUpdated(serverId, signer, false); - } - - /// @notice Returns whether the address is an active signer for the server. - function isServerSigner(bytes32 serverId, address signer) external view returns (bool) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).isSigner(signer); - } - - /// @notice Returns list of all active signer addresses for the server (rewards-get-whitelist-signers). - function getServerSigners(bytes32 serverId) external view returns (address[] memory) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getSigners(); - } - - /// @notice Enables or disables a withdrawer for the server. Caller must be SERVER_ADMIN_ROLE on the server. - function setServerWithdrawer( - bytes32 serverId, - address account, - bool isActive - ) external { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).setWithdrawerAllowedBy(msg.sender, account, isActive); - emit ServerWithdrawerUpdated(serverId, account, isActive); - } - - /// @notice Returns whether the account is an active withdrawer for the server. - function isServerWithdrawer(bytes32 serverId, address account) external view returns (bool) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).isWithdrawer(account); - } - - /*////////////////////////////////////////////////////////////// - SIGNATURE VERIFICATION - //////////////////////////////////////////////////////////////*/ - - /** - * @dev Internal helper to verify a tenant-scoped signature. - * - * Message format (hashed then wrapped in EIP-191 prefix): - * keccak256(abi.encodePacked(contractAddress, chainId, serverId, beneficiary, expiration, tokenIds, nonce)) - */ - function _verifyServerSignature( - bytes32 serverId, - address beneficiary, - uint256 expiration, - uint256[] memory tokenIds, - uint256 nonce, - bytes calldata signature - ) internal view returns (address) { - uint256 currentChainId = block.chainid; - bytes32 message = keccak256( - abi.encode( - address(this), - currentChainId, - serverId, - beneficiary, - expiration, - tokenIds, - nonce - ) - ); - bytes32 hash = MessageHashUtils.toEthSignedMessageHash(message); - address signer = ECDSA.recover(hash, signature); - - Server storage s = _getServer(serverId); - if (!RewardsServer(s.treasury).isSigner(signer)) { - revert InvalidSignature(); - } - - if (block.timestamp >= expiration) { - revert InvalidSignature(); - } - - return signer; - } - - /*////////////////////////////////////////////////////////////// - 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(); - } - - /*////////////////////////////////////////////////////////////// - TREASURY MANAGEMENT (PER TENANT) - //////////////////////////////////////////////////////////////*/ - - /// @notice Adds a token to the server whitelist. Only MANAGER_ROLE. - function whitelistToken( - bytes32 serverId, - address token, - LibItems.RewardType rewardType - ) external onlyRole(MANAGER_ROLE) { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).whitelistToken(token, rewardType); - } - - /// @notice Removes a token from the server whitelist (fails if token has reserves). Only MANAGER_ROLE. - function removeTokenFromWhitelist( - bytes32 serverId, - address token - ) external onlyRole(MANAGER_ROLE) { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).removeTokenFromWhitelist(token); - } - - /// @notice Deposits ERC20 from msg.sender into the server treasury. Token must be whitelisted. Reentrancy-protected. - function depositToTreasury( - bytes32 serverId, - address token, - uint256 amount - ) external nonReentrant { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).depositToTreasury(token, amount, msg.sender); - } - - /*////////////////////////////////////////////////////////////// - REWARD TOKEN CREATION AND SUPPLY (PER TENANT) - //////////////////////////////////////////////////////////////*/ - - /// @notice Creates a new reward token on the server and reserves/deposits rewards. Send ETH if token has ETHER rewards. Only MANAGER_ROLE. - /// @param serverId Server id. - /// @param token Reward token definition (tokenId, maxSupply, rewards, tokenUri). ERC721 rewards require exact rewardTokenIds length = rewardAmount * maxSupply. - function createTokenAndDepositRewards( - bytes32 serverId, - LibItems.RewardToken calldata token - ) external payable onlyRole(MANAGER_ROLE) nonReentrant { - uint256 ethRequired = _calculateETHRequiredForToken(token); - if (msg.value < ethRequired) revert InsufficientBalance(); - Server storage s = _getServer(serverId); - _validateAndCreateTokenAndDepositRewards(s.treasury, token); - ethReservedTotal += ethRequired; - emit TokenAdded(serverId, token.tokenId); - } - - /// @notice Pauses or unpauses minting for a reward token. Only MANAGER_ROLE. - function updateTokenMintPaused( - bytes32 serverId, - uint256 tokenId, - bool isPaused - ) external onlyRole(MANAGER_ROLE) { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).setTokenMintPaused(tokenId, isPaused); - emit TokenMintPausedUpdated(serverId, tokenId, isPaused); - } - - /// @notice Pauses or unpauses claiming for a reward token. Only MANAGER_ROLE. - function updateClaimRewardPaused( - bytes32 serverId, - uint256 tokenId, - bool isPaused - ) external onlyRole(MANAGER_ROLE) { - Server storage s = _getServer(serverId); - RewardsServer(s.treasury).setClaimRewardPaused(tokenId, isPaused); - emit ClaimRewardPausedUpdated(serverId, tokenId, isPaused); - } - - /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155/ETH. Only MANAGER_ROLE. Not supported for ERC721-backed rewards (create a new token instead). - /// @param serverId Server id. - /// @param tokenId Reward token id. - /// @param additionalSupply Amount to add. Send ETH if token has ETHER rewards (amount = sum of ETHER rewardAmount * additionalSupply). - function increaseRewardSupply( - bytes32 serverId, - uint256 tokenId, - uint256 additionalSupply - ) external payable onlyRole(MANAGER_ROLE) { - Server storage s = _getServer(serverId); - RewardsServer serverContract = RewardsServer(s.treasury); - if (!serverContract.isTokenExists(tokenId)) revert TokenNotExist(); - if (additionalSupply == 0) revert InvalidAmount(); - LibItems.RewardToken memory rewardToken = serverContract.getRewardToken(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; - } - } - if (additionalEthRequired > 0) { - if (msg.value < additionalEthRequired) revert InsufficientBalance(); - ethReservedTotal += additionalEthRequired; - } - uint256 oldSupply = rewardToken.maxSupply; - serverContract.increaseRewardSupply(tokenId, additionalSupply); - emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply + additionalSupply); - } - - /// @notice Reduces max supply of a reward token on the server. Only MANAGER_ROLE. For admin/DB parity. - function reduceRewardSupply( - bytes32 serverId, - uint256 tokenId, - uint256 reduceBy - ) external onlyRole(MANAGER_ROLE) { - Server storage s = _getServer(serverId); - RewardsServer serverContract = RewardsServer(s.treasury); - uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; - serverContract.reduceRewardSupply(tokenId, reduceBy); - emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply - reduceBy); - } - - /// @notice Updates the token URI for a reward token. Only MANAGER_ROLE. - function updateTokenUri( - bytes32 serverId, - uint256 tokenId, - string calldata newUri - ) external onlyRole(MANAGER_ROLE) { - Server storage s = _getServer(serverId); - _updateTokenUri(s.treasury, tokenId, newUri); - emit TokenURIChanged(serverId, tokenId, newUri); - } - - function _transferEther(address payable to, uint256 amount) private { - if (address(this).balance < amount) revert InsufficientBalance(); - (bool ok, ) = to.call{ value: amount }(""); - if (!ok) revert TransferFailed(); - } - - function _calculateETHRequiredForToken( - LibItems.RewardToken calldata token - ) internal pure returns (uint256) { - uint256 total; - for (uint256 i = 0; i < token.rewards.length; i++) { - if (token.rewards[i].rewardType == LibItems.RewardType.ETHER) { - total += token.rewards[i].rewardAmount; - } - } - return total * token.maxSupply; - } - - function _validateAndCreateTokenAndDepositRewards( - address treasuryAddr, - LibItems.RewardToken calldata token - ) internal { - RewardsServer server = RewardsServer(treasuryAddr); - if (token.maxSupply == 0) revert InvalidAmount(); - if ( - bytes(token.tokenUri).length == 0 || - token.rewards.length == 0 || - token.tokenId == 0 - ) revert InvalidInput(); - if (server.isTokenExists(token.tokenId)) revert DupTokenId(); - - 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.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(); - } - - for (uint256 i = 0; i < token.rewards.length; i++) { - LibItems.Reward memory r = token.rewards[i]; - if (r.rewardType == LibItems.RewardType.ERC20) { - if (!server.whitelistedTokens(r.rewardTokenAddress)) revert TokenNotWhitelisted(); - uint256 totalAmount = r.rewardAmount * token.maxSupply; - uint256 balance = IERC20(r.rewardTokenAddress).balanceOf(treasuryAddr); - uint256 reserved = server.reservedAmounts(r.rewardTokenAddress); - if (balance < reserved + totalAmount) revert InsufficientTreasuryBalance(); - server.increaseERC20Reserved(r.rewardTokenAddress, totalAmount); - } else if (r.rewardType == LibItems.RewardType.ERC721) { - if (!server.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) != treasuryAddr || server.isErc721Reserved(r.rewardTokenAddress, tid)) { - revert InsufficientTreasuryBalance(); - } - } - for (uint256 j = 0; j < r.rewardTokenIds.length; j++) { - server.reserveERC721(r.rewardTokenAddress, r.rewardTokenIds[j]); - } - } else if (r.rewardType == LibItems.RewardType.ERC1155) { - if (!server.whitelistedTokens(r.rewardTokenAddress)) revert TokenNotWhitelisted(); - uint256 totalAmount = r.rewardAmount * token.maxSupply; - uint256 balance = IERC1155(r.rewardTokenAddress).balanceOf(treasuryAddr, r.rewardTokenId); - uint256 reserved = server.erc1155ReservedAmounts(r.rewardTokenAddress, r.rewardTokenId); - if (balance < reserved + totalAmount) revert InsufficientTreasuryBalance(); - server.increaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, totalAmount); - } - } - - server.addRewardToken(token.tokenId, token); - } - - function _updateTokenUri( - address treasuryAddr, - uint256 tokenId, - string calldata newUri - ) internal { - RewardsServer server = RewardsServer(treasuryAddr); - if (!server.isTokenExists(tokenId)) revert TokenNotExist(); - LibItems.RewardToken memory rt = server.getRewardToken(tokenId); - rt.tokenUri = newUri; - server.updateRewardToken(tokenId, rt); - } - - function _distributeReward( - address treasuryAddr, - address to, - uint256 rewardTokenId - ) internal { - RewardsServer serverContract = RewardsServer(treasuryAddr); - LibItems.RewardToken memory rewardToken = serverContract.getRewardToken(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) { - ethReservedTotal -= r.rewardAmount; - _transferEther(payable(to), r.rewardAmount); - } else if (r.rewardType == LibItems.RewardType.ERC20) { - serverContract.distributeERC20(r.rewardTokenAddress, to, r.rewardAmount); - serverContract.decreaseERC20Reserved(r.rewardTokenAddress, r.rewardAmount); - } else if (r.rewardType == LibItems.RewardType.ERC721) { - uint256 currentIndex = serverContract.getERC721RewardCurrentIndex(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]; - serverContract.releaseERC721(r.rewardTokenAddress, nftId); - serverContract.distributeERC721(r.rewardTokenAddress, to, nftId); - } - serverContract.incrementERC721RewardIndex(rewardTokenId, i, r.rewardAmount); - } else if (r.rewardType == LibItems.RewardType.ERC1155) { - serverContract.decreaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, r.rewardAmount); - serverContract.distributeERC1155(r.rewardTokenAddress, to, r.rewardTokenId, r.rewardAmount); - } - } - } - - function _claimRewards( - address treasuryAddr, - address to, - uint256 tokenId, - uint256 amount - ) internal { - RewardsServer server = RewardsServer(treasuryAddr); - if (to == address(0)) revert AddressIsZero(); - if (!server.isTokenExists(tokenId)) revert TokenNotExist(); - if (server.isClaimRewardPaused(tokenId)) revert ClaimRewardPaused(); - if (amount == 0) revert InvalidAmount(); - - uint256 newSupply = server.currentRewardSupply(tokenId) + amount; - if (newSupply > server.getRewardToken(tokenId).maxSupply) revert ExceedMaxSupply(); - - for (uint256 i = 0; i < amount; i++) { - _distributeReward(treasuryAddr, to, tokenId); - } - - server.increaseCurrentSupply(tokenId, amount); - } - - /// @notice Decodes claim data for debugging. Same encoding as used in claim(serverId, data, nonce, signature). - function decodeData( - bytes calldata data - ) external pure returns (address contractAddress, uint256 chainId, address beneficiary, uint256 expiration, uint256[] memory tokenIds) { - (contractAddress, chainId, beneficiary, expiration, tokenIds) = - abi.decode(data, (address, uint256, address, uint256, uint256[])); - } - - function _decodeClaimData( - bytes calldata data - ) private pure returns (address contractAddress, uint256 chainId, address beneficiary, uint256 expiration, uint256[] memory tokenIds) { - (contractAddress, chainId, beneficiary, expiration, tokenIds) = - abi.decode(data, (address, uint256, address, uint256, uint256[])); - } - - /// @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). tokenIds length capped by MAX_CLAIM_TOKEN_IDS. - /// @param serverId Server id. - /// @param data ABI-encoded (contractAddress, chainId, beneficiary, expiration, tokenIds). - /// @param nonce User nonce (must not be used before). - /// @param signature Server signer signature over the claim message. - function claim( - bytes32 serverId, - bytes calldata data, - uint256 nonce, - bytes calldata signature - ) external nonReentrant whenNotPaused { - ( - address contractAddress, - uint256 chainId, - address beneficiary, - uint256 expiration, - uint256[] memory tokenIds - ) = _decodeClaimData(data); - - if (contractAddress != address(this) || chainId != block.chainid) revert InvalidInput(); - if (tokenIds.length > MAX_CLAIM_TOKEN_IDS) revert InvalidInput(); - - Server storage s = _getServer(serverId); - RewardsServer serverContract = RewardsServer(s.treasury); - if (serverContract.userNonces(beneficiary, nonce)) revert NonceAlreadyUsed(); - - _verifyServerSignature(serverId, beneficiary, expiration, tokenIds, nonce, signature); - serverContract.setUserNonce(beneficiary, nonce, true); - - for (uint256 i = 0; i < tokenIds.length; i++) { - _claimRewards(s.treasury, beneficiary, tokenIds[i], 1); - emit Claimed(serverId, beneficiary, tokenIds[i], 1); - } - } - - /// @notice Withdraws assets from manager (ETHER) or server treasury (ERC20/721/1155) to recipient. Only MANAGER_ROLE. ETHER: amounts[0]; ERC721: tokenIds; ERC1155: tokenIds + amounts. - function withdrawAssets( - bytes32 serverId, - LibItems.RewardType rewardType, - address to, - address tokenAddress, - uint256[] calldata tokenIds, - uint256[] calldata amounts - ) external onlyRole(MANAGER_ROLE) { - if (to == address(0)) revert AddressIsZero(); - Server storage s = _getServer(serverId); - RewardsServer serverContract = RewardsServer(s.treasury); - - if (rewardType == LibItems.RewardType.ETHER) { - if (amounts.length == 0) revert InvalidInput(); - uint256 amount = amounts[0]; - if (amount > address(this).balance - ethReservedTotal) revert InsufficientBalance(); - _transferEther(payable(to), amount); - } else if (rewardType == LibItems.RewardType.ERC20) { - serverContract.withdrawUnreservedTreasury(tokenAddress, to); - } else if (rewardType == LibItems.RewardType.ERC721) { - for (uint256 i = 0; i < tokenIds.length; i++) { - serverContract.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++) { - serverContract.withdrawERC1155UnreservedTreasury(tokenAddress, to, tokenIds[i], amounts[i]); - } - } - uint256 emittedAmount = rewardType == LibItems.RewardType.ERC721 - ? tokenIds.length - : (amounts.length > 0 ? amounts[0] : 0); - emit AssetsWithdrawn(serverId, rewardType, to, emittedAmount); - } - - /*////////////////////////////////////////////////////////////// - TREASURY WITHDRAW (PER TENANT) - //////////////////////////////////////////////////////////////*/ - - /// @notice Withdraws all unreserved ERC20 for token to to. Caller must be server withdrawer. Reentrancy-protected. - function withdrawUnreservedTreasury( - bytes32 serverId, - address token, - address to - ) external nonReentrant { - Server storage s = _getServer(serverId); - if (!RewardsServer(s.treasury).isWithdrawer(msg.sender)) revert UnauthorizedServerAdmin(); - RewardsServer(s.treasury).withdrawUnreservedTreasury(token, to); - } - - /// @notice Withdraws one unreserved ERC721 (tokenId) to to. Caller must be server withdrawer. Reentrancy-protected. - function withdrawERC721UnreservedTreasury( - bytes32 serverId, - address token, - address to, - uint256 tokenId - ) external nonReentrant { - Server storage s = _getServer(serverId); - if (!RewardsServer(s.treasury).isWithdrawer(msg.sender)) revert UnauthorizedServerAdmin(); - RewardsServer(s.treasury).withdrawERC721UnreservedTreasury(token, to, tokenId); - } - - /// @notice Withdraws unreserved ERC1155 amount to to. Caller must be server withdrawer. Reentrancy-protected. - function withdrawERC1155UnreservedTreasury( - bytes32 serverId, - address token, - address to, - uint256 tokenId, - uint256 amount - ) external nonReentrant { - Server storage s = _getServer(serverId); - if (!RewardsServer(s.treasury).isWithdrawer(msg.sender)) revert UnauthorizedServerAdmin(); - RewardsServer(s.treasury).withdrawERC1155UnreservedTreasury(token, to, tokenId, amount); - } - - /*////////////////////////////////////////////////////////////// - VIEW HELPERS (PER TENANT) - //////////////////////////////////////////////////////////////*/ - - /// @notice Returns full treasury balance view for the server (addresses, total/reserved/available, symbols, names, types, tokenIds). - function getServerTreasuryBalances( - bytes32 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 - ) - { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getAllTreasuryBalances(); - } - - /// @notice Returns all reward token ids (item ids) for the server. - function getServerAllItemIds(bytes32 serverId) external view returns (uint256[] memory) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getAllItemIds(); - } - - /// @notice Returns reward definitions for a reward token on the server. - function getServerTokenRewards( - bytes32 serverId, - uint256 tokenId - ) external view returns (LibItems.Reward[] memory) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getTokenRewards(tokenId); - } - - /// @notice Returns server treasury ERC20 balance for token. - function getServerTreasuryBalance( - bytes32 serverId, - address token - ) external view returns (uint256) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getTreasuryBalance(token); - } - - /// @notice Returns reserved amount for token on the server. - function getServerReservedAmount( - bytes32 serverId, - address token - ) external view returns (uint256) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getReservedAmount(token); - } - - /// @notice Returns unreserved (available) treasury balance for token on the server. - function getServerAvailableTreasuryBalance( - bytes32 serverId, - address token - ) external view returns (uint256) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getAvailableTreasuryBalance(token); - } - - /// @notice Returns whitelisted token addresses for the server. - function getServerWhitelistedTokens( - bytes32 serverId - ) external view returns (address[] memory) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).getWhitelistedTokens(); - } - - /// @notice Returns whether token is whitelisted on the server. - function isServerWhitelistedToken( - bytes32 serverId, - address token - ) external view returns (bool) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).isWhitelistedToken(token); - } - - /// @notice Returns whether the reward token exists on the server. - function isTokenExist(bytes32 serverId, uint256 tokenId) public view returns (bool) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).isTokenExists(tokenId); - } - - /// @notice Returns whether minting is paused for the reward token on the server. - function isTokenMintPaused(bytes32 serverId, uint256 tokenId) external view returns (bool) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).isTokenMintPaused(tokenId); - } - - /// @notice Returns whether claiming is paused for the reward token on the server. - function isClaimRewardPaused(bytes32 serverId, uint256 tokenId) external view returns (bool) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).isClaimRewardPaused(tokenId); - } - - /// @notice Returns structured reward token details (URI, maxSupply, reward types/amounts/addresses/tokenIds). - function getTokenDetails( - bytes32 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 - ) - { - Server storage s = _getServer(serverId); - LibItems.RewardToken memory rt = RewardsServer(s.treasury).getRewardToken(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 Returns remaining claimable supply for a reward token (maxSupply - currentSupply), or 0 if exhausted/nonexistent. - function getRemainingSupply( - bytes32 serverId, - uint256 tokenId - ) external view returns (uint256) { - Server storage s = _getServer(serverId); - RewardsServer serverContract = RewardsServer(s.treasury); - if (!serverContract.isTokenExists(tokenId)) return 0; - uint256 maxSupply = serverContract.getRewardToken(tokenId).maxSupply; - uint256 current = serverContract.currentRewardSupply(tokenId); - if (current >= maxSupply) return 0; - return maxSupply - current; - } - - /// @notice Returns whether the user has already used the given nonce (replay protection). - function isNonceUsed( - bytes32 serverId, - address user, - uint256 nonce - ) external view returns (bool) { - Server storage s = _getServer(serverId); - return RewardsServer(s.treasury).userNonces(user, nonce); - } - - receive() external payable {} -} - diff --git a/contracts/upgradeables/soulbounds/RewardsRouter.sol b/contracts/upgradeables/soulbounds/RewardsRouter.sol new file mode 100644 index 0000000..90cf96d --- /dev/null +++ b/contracts/upgradeables/soulbounds/RewardsRouter.sol @@ -0,0 +1,717 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +// @author Summon.xyz Team - https://summon.xyz +// @contributors: [ @ogarciarevett, @karacurt] + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +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 { + MessageHashUtils +} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.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 +{ + using SafeERC20 for IERC20; + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + error AddressIsZero(); + error ServerAlreadyExists(); + error ServerDoesNotExist(); + error InvalidServerId(); + error BeaconNotInitialized(); + error BeaconsAlreadyInitialized(); + error InvalidSignature(); + error UnauthorizedServerAdmin(); + error InsufficientBalance(); + error InvalidAmount(); + error InvalidInput(); + error NonceAlreadyUsed(); + error TokenNotExist(); + error ExceedMaxSupply(); + error MintPaused(); + error ClaimRewardPaused(); + error DupTokenId(); + error TokenNotWhitelisted(); + error InsufficientTreasuryBalance(); + error TransferFailed(); + error InvalidLength(); + + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + uint256 public constant MAX_CLAIM_TOKEN_IDS = 50; + + 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"); + + + /*////////////////////////////////////////////////////////////// + STRUCTS + //////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////// + 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 treasuryBeacon; + + uint256[44] private __gap; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + event ServerDeployed(uint8 indexed serverId, address treasury); + + event ServerSignerUpdated( + uint8 indexed serverId, + address indexed signer, + bool isActive + ); + + event ServerAdminTransferred( + uint8 indexed serverId, + address indexed oldAdmin, + address indexed newAdmin + ); + + event TokenAdded(uint8 indexed serverId, uint256 indexed tokenId); + event Minted( + uint8 indexed serverId, + address indexed to, + uint256 indexed tokenId, + uint256 amount, + bool soulbound + ); + event Claimed( + uint8 indexed serverId, + address indexed to, + uint256 indexed tokenId + ); + event TokenMintPausedUpdated( + uint8 indexed serverId, + uint256 indexed tokenId, + bool isPaused + ); + event ClaimRewardPausedUpdated( + uint8 indexed serverId, + uint256 indexed tokenId, + bool isPaused + ); + event RewardSupplyChanged( + uint8 indexed serverId, + uint256 indexed tokenId, + uint256 oldSupply, + uint256 newSupply + ); + event TokenURIChanged( + uint8 indexed serverId, + uint256 indexed tokenId, + string newUri + ); + event AssetsWithdrawn( + uint8 indexed serverId, + LibItems.RewardType rewardType, + address indexed to, + uint256 amount + ); + event ServerRoleGranted( + uint8 indexed serverId, + bytes32 indexed role, + address indexed to + ); + event ServerRoleRevoked( + uint8 indexed serverId, + bytes32 indexed role, + address indexed from + ); + + /*////////////////////////////////////////////////////////////// + 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 _routerWallet Receives MANAGER_ROLE. + function initialize( + address _devWallet, + address _routerWallet + ) external initializer { + if ( + _devWallet == address(0) || + _routerWallet == 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, _routerWallet); + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyRole(UPGRADER_ROLE) {} + + modifier onlyServerAdmin(uint8 serverId) { + address server = _getServer(serverId); + if (!RewardsServer(payable(server)).isServerAdmin(msg.sender)) revert UnauthorizedServerAdmin(); + _; + } + + /*////////////////////////////////////////////////////////////// + BEACON CONFIGURATION + //////////////////////////////////////////////////////////////*/ + + /// @notice Sets the RewardsServer implementation beacon. Callable once by DEV_CONFIG_ROLE. + /// @param _treasuryImplementation Implementation contract for RewardsServer (BeaconProxy targets this). + function initializeBeacons(address _treasuryImplementation) external onlyRole(DEV_CONFIG_ROLE) { + if (address(_treasuryImplementation) == address(0)) { + revert AddressIsZero(); + } + if (address(treasuryBeacon) != address(0)) { + revert BeaconsAlreadyInitialized(); + } + + treasuryBeacon = address(new UpgradeableBeacon( + _treasuryImplementation, + address(this) + )); + } + + /*////////////////////////////////////////////////////////////// + SERVER MANAGEMENT + //////////////////////////////////////////////////////////////*/ + + function _getServer(uint8 serverId) internal view returns (address treasury) { + treasury = servers[serverId]; + if (treasury == address(0)) { + revert ServerDoesNotExist(); + } + } + + /// @notice Registers a new server (RewardsServer proxy) for the given serverId. Only FACTORY_ROLE (RewardsFactory). + /// @param serverId Unique server identifier (small uint8). + /// @param server Address of the deployed RewardsServer proxy. + function registerServer(uint8 serverId, address server) external onlyRole(MANAGER_ROLE) { + if (serverId == 0) revert InvalidServerId(); + if (servers[serverId] != address(0)) revert ServerAlreadyExists(); + if (server == address(0)) revert AddressIsZero(); + + servers[serverId] = server; + emit ServerDeployed(serverId, server); + } + + /// @notice Deploys and registers a new RewardsServer treasury for the given serverId. Only FACTORY_ROLE. + /// @dev Caller becomes SERVER_ADMIN_ROLE on the new server. + /// @param serverId Unique server identifier (small uint8). + function deployServer(uint8 serverId, address serverAdmin) external onlyRole(MANAGER_ROLE) returns (address server) { + if (serverId == 0) revert InvalidServerId(); + if (servers[serverId] != address(0)) revert ServerAlreadyExists(); + if (address(treasuryBeacon) == address(0)) revert BeaconNotInitialized(); + + bytes memory initData = abi.encodeWithSelector( + RewardsServer.initialize.selector, + address(this), + address(this), + serverAdmin + ); + + server = address(new BeaconProxy(address(treasuryBeacon), initData)); + + servers[serverId] = server; + emit ServerDeployed(serverId, server); + } + + /// @notice Grants a role to an address on the server. Caller must be current SERVER_ADMIN_ROLE on the server. + function grantServerRole(uint8 serverId, bytes32 role, address to) external onlyServerAdmin(serverId) { + if (to == address(0)) revert AddressIsZero(); + address server = _getServer(serverId); + RewardsServer(payable(server)).grantRole(role, to); + emit ServerRoleGranted(serverId, role, to); + } + + /// @notice Revokes a role from an address on the server. Caller must be current SERVER_ADMIN_ROLE on the server. + function revokeServerRole(uint8 serverId, bytes32 role, address from) external onlyServerAdmin(serverId) { + if (from == address(0)) revert AddressIsZero(); + address server = _getServer(serverId); + RewardsServer(payable(server)).revokeRole(role, from); + emit ServerRoleRevoked(serverId, role, from); + } + + /// @notice Returns the RewardsServer (treasury) address for a server. + function getServer(uint8 serverId) external view returns (address server) { + return _getServer(serverId); + } + + /*////////////////////////////////////////////////////////////// + PER-SERVER ACCESS CONTROL + //////////////////////////////////////////////////////////////*/ + + /// @notice Adds a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. + function addWhitelistSigner(uint8 serverId, address signer) external onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer(payable(server)).setSigner(signer, true); + emit ServerSignerUpdated(serverId, signer, true); + } + + /// @notice Removes a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. + function removeWhitelistSigner(uint8 serverId, address signer) external { + address server = _getServer(serverId); + RewardsServer(payable(server)).setSigner(signer, false); + emit ServerSignerUpdated(serverId, signer, false); + } + + /// @notice Returns whether the address is an active signer for the server. + function isServerSigner(uint8 serverId, address signer) external view returns (bool) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).isSigner(signer); + } + + /// @notice Returns list of all active signer addresses for the server (rewards-get-whitelist-signers). + function getServerSigners(uint8 serverId) external view returns (address[] memory) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).getSigners(); + } + + /*////////////////////////////////////////////////////////////// + SIGNATURE VERIFICATION + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Internal helper to verify a tenant-scoped signature. + * + * Message format (hashed then wrapped in EIP-191 prefix): + * keccak256(abi.encodePacked(contractAddress, chainId, serverId, beneficiary, expiration, tokenIds, nonce)) + */ + function _verifyServerSignature( + uint8 serverId, + address beneficiary, + uint256 expiration, + uint256[] memory tokenIds, + uint256 nonce, + bytes calldata signature + ) internal view returns (address) { + uint256 currentChainId = block.chainid; + bytes32 message = keccak256( + abi.encode( + address(this), + currentChainId, + serverId, + beneficiary, + expiration, + tokenIds, + nonce + ) + ); + bytes32 hash = MessageHashUtils.toEthSignedMessageHash(message); + address signer = ECDSA.recover(hash, signature); + + address server = _getServer(serverId); + if (!RewardsServer(payable(server)).isSigner(signer)) { + revert InvalidSignature(); + } + + if (block.timestamp >= expiration) { + revert InvalidSignature(); + } + + return signer; + } + + /*////////////////////////////////////////////////////////////// + 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(); + } + + /*////////////////////////////////////////////////////////////// + TREASURY MANAGEMENT (PER TENANT) + //////////////////////////////////////////////////////////////*/ + + /// @notice Adds a token to the server whitelist. Caller must be SERVER_ADMIN_ROLE on the server. + function whitelistToken( + uint8 serverId, + address token, + LibItems.RewardType rewardType + ) external onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + serverContract.whitelistToken(token, rewardType); + } + + /// @notice Removes a token from the server whitelist (fails if token has reserves). Caller must be SERVER_ADMIN_ROLE on the server. + function removeTokenFromWhitelist( + uint8 serverId, + address token + ) external onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + serverContract.removeTokenFromWhitelist(token); + } + + /// @notice Deposits ERC20 from msg.sender into the server treasury. Token must be whitelisted. Reentrancy-protected. + function depositToTreasury( + uint8 serverId, + address token, + uint256 amount + ) external nonReentrant { + address server = _getServer(serverId); + RewardsServer(payable(server)).depositToTreasury(token, amount, msg.sender); + } + + /*////////////////////////////////////////////////////////////// + REWARD TOKEN CREATION AND SUPPLY (PER TENANT) + //////////////////////////////////////////////////////////////*/ + + /// @notice Creates a new reward token on the server and reserves/deposits rewards. Send ETH if token has ETHER rewards (forwarded to server). Caller must be SERVER_ADMIN_ROLE on the server. + /// @param serverId Server id. + /// @param token Reward token definition (tokenId, maxSupply, rewards, tokenUri). ERC721 rewards require exact rewardTokenIds length = rewardAmount * maxSupply. + function createTokenAndDepositRewards( + uint8 serverId, + LibItems.RewardToken calldata token + ) external payable nonReentrant onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + serverContract.createTokenAndReserveRewards{ value: msg.value }(token); + emit TokenAdded(serverId, token.tokenId); + } + + /// @notice Pauses or unpauses minting for a reward token. Caller must be SERVER_ADMIN_ROLE on the server. + function updateTokenMintPaused( + uint8 serverId, + uint256 tokenId, + bool isPaused + ) external onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + serverContract.setTokenMintPaused(tokenId, isPaused); + emit TokenMintPausedUpdated(serverId, tokenId, isPaused); + } + + /// @notice Pauses or unpauses claiming for a reward token. Caller must be SERVER_ADMIN_ROLE on the server. + function updateClaimRewardPaused( + uint8 serverId, + uint256 tokenId, + bool isPaused + ) external onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + serverContract.setClaimRewardPaused(tokenId, isPaused); + emit ClaimRewardPausedUpdated(serverId, tokenId, isPaused); + } + + /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155/ETH on server. Caller must be SERVER_ADMIN_ROLE on the server. Send ETH if token has ETHER rewards (forwarded to server). + /// @param serverId Server id. + /// @param tokenId Reward token id. + /// @param additionalSupply Amount to add. Not supported for ERC721-backed rewards (create a new token instead). + function increaseRewardSupply( + uint8 serverId, + uint256 tokenId, + uint256 additionalSupply + ) external payable onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + if (!serverContract.isTokenExists(tokenId)) revert TokenNotExist(); + if (additionalSupply == 0) revert InvalidAmount(); + uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; + serverContract.increaseRewardSupply{ value: msg.value }(tokenId, additionalSupply); + emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply + additionalSupply); + } + + /// @notice Reduces max supply of a reward token on the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. + function reduceRewardSupply( + uint8 serverId, + uint256 tokenId, + uint256 reduceBy + ) external onlyServerAdmin(serverId) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; + serverContract.reduceRewardSupply(tokenId, reduceBy); + emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply - reduceBy); + } + + /// @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 expiration, uint256[] memory tokenIds) { + (contractAddress, chainId, beneficiary, expiration, tokenIds) = + abi.decode(data, (address, uint256, address, uint256, uint256[])); + } + + /// @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). tokenIds length capped by MAX_CLAIM_TOKEN_IDS. + /// @param serverId Server id. + /// @param data ABI-encoded (contractAddress, chainId, beneficiary, expiration, tokenIds). + /// @param nonce User nonce (must not be used before). + /// @param signature Server signer signature over the claim message. + function claim( + uint8 serverId, + bytes calldata data, + uint256 nonce, + bytes calldata signature + ) external nonReentrant whenNotPaused { + ( + address contractAddress, + uint256 chainId, + address beneficiary, + uint256 expiration, + uint256[] memory tokenIds + ) = decodeClaimData(data); + + if (contractAddress != address(this) || chainId != block.chainid) revert InvalidInput(); + if (tokenIds.length > MAX_CLAIM_TOKEN_IDS) revert InvalidInput(); + + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + if (serverContract.userNonces(beneficiary, nonce)) revert NonceAlreadyUsed(); + + _verifyServerSignature(serverId, beneficiary, expiration, tokenIds, nonce, signature); + serverContract.setUserNonce(beneficiary, nonce, true); + + for (uint256 i = 0; i < tokenIds.length; i++) { + serverContract.claimReward(beneficiary, tokenIds[i]); + emit Claimed(serverId, beneficiary, tokenIds[i]); + } + } + + /// @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( + uint8 serverId, + LibItems.RewardType rewardType, + address to, + address tokenAddress, + uint256[] calldata tokenIds, + uint256[] calldata amounts + ) external onlyServerAdmin(serverId) { + if (to == address(0)) revert AddressIsZero(); + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + + if (rewardType == LibItems.RewardType.ETHER) { + if (amounts.length == 0) revert InvalidInput(); + serverContract.withdrawEtherUnreservedTreasury(to, amounts[0]); + } else if (rewardType == LibItems.RewardType.ERC20) { + serverContract.withdrawUnreservedTreasury(tokenAddress, to); + } else if (rewardType == LibItems.RewardType.ERC721) { + for (uint256 i = 0; i < tokenIds.length; i++) { + serverContract.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++) { + serverContract.withdrawERC1155UnreservedTreasury(tokenAddress, to, tokenIds[i], amounts[i]); + } + } + uint256 emittedAmount = rewardType == LibItems.RewardType.ERC721 + ? tokenIds.length + : (amounts.length > 0 ? amounts[0] : 0); + emit AssetsWithdrawn(serverId, rewardType, to, emittedAmount); + } + + /*////////////////////////////////////////////////////////////// + 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 + ) + { + address server = _getServer(serverId); + return RewardsServer(payable(server)).getAllTreasuryBalances(); + } + + /// @notice Returns all reward token ids (item ids) for the server. + function getServerAllItemIds(uint8 serverId) external view returns (uint256[] memory) { + address server = _getServer(serverId); + return RewardsServer(payable(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) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).getTokenRewards(tokenId); + } + + /// @notice Returns server treasury ERC20 balance for token. + function getServerTreasuryBalance( + uint8 serverId, + address token + ) external view returns (uint256) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).getTreasuryBalance(token); + } + + /// @notice Returns reserved amount for token on the server. + function getServerReservedAmount( + uint8 serverId, + address token + ) external view returns (uint256) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).getReservedAmount(token); + } + + /// @notice Returns unreserved (available) treasury balance for token on the server. + function getServerAvailableTreasuryBalance( + uint8 serverId, + address token + ) external view returns (uint256) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).getAvailableTreasuryBalance(token); + } + + /// @notice Returns whitelisted token addresses for the server. + function getServerWhitelistedTokens( + uint8 serverId + ) external view returns (address[] memory) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).getWhitelistedTokens(); + } + + /// @notice Returns whether token is whitelisted on the server. + function isServerWhitelistedToken( + uint8 serverId, + address token + ) external view returns (bool) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).isWhitelistedToken(token); + } + + /// @notice Returns whether the reward token exists on the server. + function isTokenExist(uint8 serverId, uint256 tokenId) public view returns (bool) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).isTokenExists(tokenId); + } + + /// @notice Returns whether minting is paused for the reward token on the server. + function isTokenMintPaused(uint8 serverId, uint256 tokenId) external view returns (bool) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).isTokenMintPaused(tokenId); + } + + /// @notice Returns whether claiming is paused for the reward token on the server. + function isClaimRewardPaused(uint8 serverId, uint256 tokenId) external view returns (bool) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).isClaimRewardPaused(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 + ) + { + address server = _getServer(serverId); + return RewardsServer(payable(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) { + address server = _getServer(serverId); + RewardsServer serverContract = RewardsServer(payable(server)); + if (!serverContract.isTokenExists(tokenId)) return 0; + uint256 maxSupply = serverContract.getRewardToken(tokenId).maxSupply; + uint256 current = serverContract.currentRewardSupply(tokenId); + if (current >= maxSupply) return 0; + return maxSupply - current; + } + + /// @notice Returns whether the user has already used the given nonce (replay protection). + function isNonceUsed( + uint8 serverId, + address user, + uint256 nonce + ) external view returns (bool) { + address server = _getServer(serverId); + return RewardsServer(payable(server)).userNonces(user, nonce); + } + + receive() external payable {} +} + diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol index 9b2775d..db1c6dc 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -16,11 +16,6 @@ import { ERC721HolderUpgradeable } from "@openzeppelin/contracts-upgradeable/tok import { ERC1155HolderUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC1155/utils/ERC1155HolderUpgradeable.sol"; import { LibItems } from "../../libraries/LibItems.sol"; -interface IRewards { - function getAllItemIds() external view returns (uint256[] memory); - function getTokenRewards(uint256 tokenId) external view returns (LibItems.Reward[] memory); -} - interface IERC1155Metadata { function name() external view returns (string memory); function symbol() external view returns (string memory); @@ -49,21 +44,25 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU error InsufficientBalance(); error UnauthorizedServerAdmin(); error InsufficientERC721Ids(); + error ExceedMaxSupply(); + error ClaimRewardPaused(); + error InvalidInput(); + error DupTokenId(); + error TransferFailed(); /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ - bytes32 public constant REWARDS_MANAGER_ROLE = keccak256("REWARDS_MANAGER_ROLE"); + bytes32 public constant REWARDS_ROUTER_ROLE = keccak256("REWARDS_ROUTER_ROLE"); bytes32 public constant SERVER_ADMIN_ROLE = keccak256("SERVER_ADMIN_ROLE"); /*////////////////////////////////////////////////////////////// STATE VARIABLES //////////////////////////////////////////////////////////////*/ - // Server-level access (admin, signers for claim, withdrawers) + // Server-level access (admin, signers for claim) mapping(address => bool) public signers; address[] private signerList; - mapping(address => bool) public withdrawers; // Whitelist mapping(address => bool) public whitelistedTokens; @@ -89,14 +88,19 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU // Per-user nonce (for mint/claim signatures) mapping(address => mapping(uint256 => bool)) public userNonces; - uint256[32] private __gap; + // RewardsRouter (has REWARDS_ROUTER_ROLE on this server) + address private rewardsRouter; + + // ETH reserved for pending ETHER rewards (this server holds all its treasury ETH) + uint256 public ethReservedTotal; + + uint256[30] private __gap; /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ event TreasuryDeposit(address indexed token, uint256 amount); event SignerUpdated(address indexed account, bool active); - event WithdrawerUpdated(address indexed account, bool active); event ServerAdminTransferred(address indexed oldAdmin, address indexed newAdmin); /*////////////////////////////////////////////////////////////// @@ -108,8 +112,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } /// @notice Initializes the server: access roles and server admin. Called once by the proxy. - /// @param _admin Default admin (e.g. RewardsManager or deployer). - /// @param _rewardsContract Address that receives REWARDS_MANAGER_ROLE (typically RewardsManager). + /// @param _admin Default admin (e.g. RewardsRouter or deployer). + /// @param _rewardsContract Address that receives REWARDS_ROUTER_ROLE (typically RewardsRouter). /// @param _serverAdmin Address that receives SERVER_ADMIN_ROLE (signers, withdrawers, transfer). function initialize(address _admin, address _rewardsContract, address _serverAdmin) external initializer { if (_admin == address(0) || _rewardsContract == address(0) || _serverAdmin == address(0)) { @@ -121,51 +125,15 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU __ERC1155Holder_init(); _grantRole(DEFAULT_ADMIN_ROLE, _admin); - _grantRole(REWARDS_MANAGER_ROLE, _rewardsContract); + _grantRole(REWARDS_ROUTER_ROLE, _rewardsContract); _grantRole(SERVER_ADMIN_ROLE, _serverAdmin); + rewardsRouter = _rewardsContract; } /*////////////////////////////////////////////////////////////// - SERVER ACCESS CONTROL (admin, signers, withdrawers) + SERVER ACCESS CONTROL (admin, signers) //////////////////////////////////////////////////////////////*/ - /// @notice Enables or disables a claim signer. Only SERVER_ADMIN_ROLE. - /// @param account Signer address. - /// @param active True to allow signing, false to revoke. - function setSigner(address account, bool active) external onlyRole(SERVER_ADMIN_ROLE) { - if (account == address(0)) revert AddressIsZero(); - if (active) { - if (!signers[account]) { - signers[account] = true; - signerList.push(account); - } - } else { - if (signers[account]) { - signers[account] = false; - _removeFromSignerList(account); - } - } - emit SignerUpdated(account, active); - } - - /// @notice Enables or disables a withdrawer. Only SERVER_ADMIN_ROLE. - /// @param account Withdrawer address. - /// @param active True to allow withdrawals, false to revoke. - function setWithdrawer(address account, bool active) external onlyRole(SERVER_ADMIN_ROLE) { - if (account == address(0)) revert AddressIsZero(); - withdrawers[account] = active; - emit WithdrawerUpdated(account, active); - } - - /// @notice Transfers SERVER_ADMIN_ROLE to another address. Caller loses the role. - /// @param newAdmin Address to receive SERVER_ADMIN_ROLE. - function transferServerAdmin(address newAdmin) external onlyRole(SERVER_ADMIN_ROLE) { - if (newAdmin == address(0)) revert AddressIsZero(); - address oldAdmin = msg.sender; - _revokeRole(SERVER_ADMIN_ROLE, oldAdmin); - _grantRole(SERVER_ADMIN_ROLE, newAdmin); - emit ServerAdminTransferred(oldAdmin, newAdmin); - } /// @notice Returns whether the account is an active signer for claims. function isSigner(address account) external view returns (bool) { @@ -177,14 +145,13 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU return signerList; } - /// @notice Returns whether the account is an active withdrawer. - function isWithdrawer(address account) external view returns (bool) { - return withdrawers[account]; + /// @notice Returns whether the account is server admin for this RewardsServer. + function isServerAdmin(address account) external view returns (bool) { + return hasRole(SERVER_ADMIN_ROLE, account); } - /// @notice Same as setSigner but called by RewardsManager; caller must be SERVER_ADMIN_ROLE. - function setSignerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { - if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); + /// @notice Same as setSigner but called by RewardsRouter; caller must be SERVER_ADMIN_ROLE. + function setSigner(address account, bool active) external onlyRole(REWARDS_ROUTER_ROLE) { if (account == address(0)) revert AddressIsZero(); if (active) { if (!signers[account]) { @@ -210,16 +177,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } } - /// @notice Same as setWithdrawer but called by RewardsManager; caller must be SERVER_ADMIN_ROLE. - function setWithdrawerAllowedBy(address caller, address account, bool active) external onlyRole(REWARDS_MANAGER_ROLE) { - if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); - if (account == address(0)) revert AddressIsZero(); - withdrawers[account] = active; - emit WithdrawerUpdated(account, active); - } - - /// @notice Same as transferServerAdmin but initiated by RewardsManager; caller becomes new admin. - function transferServerAdminAllowedBy(address caller, address newAdmin) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Same as transferServerAdmin but initiated by RewardsRouter; caller becomes new admin. + function transferServerAdminAllowedBy(address caller, address newAdmin) external onlyRole(REWARDS_ROUTER_ROLE) { if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); if (newAdmin == address(0)) revert AddressIsZero(); _revokeRole(SERVER_ADMIN_ROLE, caller); @@ -231,10 +190,10 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU TREASURY MANAGEMENT FUNCTIONS //////////////////////////////////////////////////////////////*/ - /// @notice Adds a token to the whitelist for use in rewards. Only REWARDS_MANAGER_ROLE. + /// @notice Adds a token to the whitelist for use in rewards. Only REWARDS_ROUTER_ROLE. /// @param _token Token contract address. /// @param _type One of ERC20, ERC721, ERC1155. - function whitelistToken(address _token, LibItems.RewardType _type) external onlyRole(REWARDS_MANAGER_ROLE) { + function whitelistToken(address _token, LibItems.RewardType _type) external onlyRole(REWARDS_ROUTER_ROLE) { if (_token == address(0)) revert AddressIsZero(); if (whitelistedTokens[_token]) revert TokenAlreadyWhitelisted(); @@ -247,9 +206,9 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } } - /// @notice Removes a token from the whitelist. Fails if the token has any reserves. Only REWARDS_MANAGER_ROLE. + /// @notice Removes a token from the whitelist. Fails if the token has any reserves. Only REWARDS_ROUTER_ROLE. /// @param _token Token contract address. - function removeTokenFromWhitelist(address _token) external onlyRole(REWARDS_MANAGER_ROLE) { + function removeTokenFromWhitelist(address _token) external onlyRole(REWARDS_ROUTER_ROLE) { LibItems.RewardType _type = tokenTypes[_token]; if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -268,11 +227,11 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } } - /// @notice Transfers tokens from _from into this treasury. Only REWARDS_MANAGER_ROLE. Token must be whitelisted. + /// @notice Transfers tokens from _from into this treasury. Only REWARDS_ROUTER_ROLE. Token must be whitelisted. /// @param _token Token address (ERC20). /// @param _amount Amount to transfer. /// @param _from Source address (must have approved this contract). - function depositToTreasury(address _token, uint256 _amount, address _from) external onlyRole(REWARDS_MANAGER_ROLE) { + function depositToTreasury(address _token, uint256 _amount, address _from) external onlyRole(REWARDS_ROUTER_ROLE) { if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); if (_amount == 0) revert InvalidAmount(); @@ -280,8 +239,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU emit TreasuryDeposit(_token, _amount); } - /// @notice Sends all unreserved ERC20 balance of _token to _to. Only REWARDS_MANAGER_ROLE. - function withdrawUnreservedTreasury(address _token, address _to) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Sends all unreserved ERC20 balance of _token to _to. Only REWARDS_ROUTER_ROLE. + function withdrawUnreservedTreasury(address _token, address _to) external onlyRole(REWARDS_ROUTER_ROLE) { if (_to == address(0)) revert AddressIsZero(); if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -293,8 +252,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU SafeERC20.safeTransfer(IERC20(_token), _to, balance - reserved); } - /// @notice Sends one unreserved ERC721 token to _to. Only REWARDS_MANAGER_ROLE. Fails if _tokenId is reserved. - function withdrawERC721UnreservedTreasury(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Sends one unreserved ERC721 token to _to. Only REWARDS_ROUTER_ROLE. Fails if _tokenId is reserved. + function withdrawERC721UnreservedTreasury(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_ROUTER_ROLE) { if (_to == address(0)) revert AddressIsZero(); if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); if (isErc721Reserved[_token][_tokenId]) revert InsufficientTreasuryBalance(); @@ -302,8 +261,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); } - /// @notice Sends unreserved ERC1155 amount to _to. Only REWARDS_MANAGER_ROLE. - function withdrawERC1155UnreservedTreasury(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Sends unreserved ERC1155 amount to _to. Only REWARDS_ROUTER_ROLE. + function withdrawERC1155UnreservedTreasury(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_ROUTER_ROLE) { if (_to == address(0)) revert AddressIsZero(); if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -316,22 +275,31 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU IERC1155(_token).safeTransferFrom(address(this), _to, _tokenId, _amount, ""); } + /// @notice Sends unreserved ETH from this server's treasury to _to. Only REWARDS_ROUTER_ROLE. + function withdrawEtherUnreservedTreasury(address _to, uint256 _amount) external onlyRole(REWARDS_ROUTER_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 Transfers ERC20 to recipient (used when fulfilling claims). Only REWARDS_MANAGER_ROLE. - function distributeERC20(address _token, address _to, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Transfers ERC20 to recipient (used when fulfilling claims). Only REWARDS_ROUTER_ROLE. + function distributeERC20(address _token, address _to, uint256 _amount) external onlyRole(REWARDS_ROUTER_ROLE) { SafeERC20.safeTransfer(IERC20(_token), _to, _amount); } - /// @notice Transfers ERC721 to recipient (used when fulfilling claims). Only REWARDS_MANAGER_ROLE. - function distributeERC721(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Transfers ERC721 to recipient (used when fulfilling claims). Only REWARDS_ROUTER_ROLE. + function distributeERC721(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_ROUTER_ROLE) { IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); } - /// @notice Transfers ERC1155 amount to recipient (used when fulfilling claims). Only REWARDS_MANAGER_ROLE. - function distributeERC1155(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Transfers ERC1155 amount to recipient (used when fulfilling claims). Only REWARDS_ROUTER_ROLE. + function distributeERC1155(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_ROUTER_ROLE) { IERC1155(_token).safeTransferFrom(address(this), _to, _tokenId, _amount, ""); } @@ -454,6 +422,51 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU 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 Whether a reward token with _tokenId exists. function isTokenExists(uint256 _tokenId) external view returns (bool) { return tokenExists[_tokenId]; @@ -465,67 +478,72 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } /*////////////////////////////////////////////////////////////// - RESERVATION & REWARD MUTATORS (REWARDS_MANAGER_ROLE) + INTERNAL RESERVE / REWARD MUTATORS (for claim & create) //////////////////////////////////////////////////////////////*/ - - /// @notice Increases reserved ERC20 amount for _token (e.g. when creating or extending reward supply). Only REWARDS_MANAGER_ROLE. - function increaseERC20Reserved(address _token, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + function _increaseERC20Reserved(address _token, uint256 _amount) internal { reservedAmounts[_token] += _amount; } - /// @notice Decreases reserved ERC20 amount. Only REWARDS_MANAGER_ROLE. Reverts if would go below zero. - function decreaseERC20Reserved(address _token, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + function _decreaseERC20Reserved(address _token, uint256 _amount) internal { reservedAmounts[_token] -= _amount; } - /// @notice Marks an ERC721 token id as reserved for rewards. Only REWARDS_MANAGER_ROLE. - function reserveERC721(address _token, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { + function _reserveERC721(address _token, uint256 _tokenId) internal { isErc721Reserved[_token][_tokenId] = true; erc721TotalReserved[_token]++; } - /// @notice Releases reservation of an ERC721 token id. Only REWARDS_MANAGER_ROLE. - function releaseERC721(address _token, uint256 _tokenId) external onlyRole(REWARDS_MANAGER_ROLE) { + function _releaseERC721(address _token, uint256 _tokenId) internal { isErc721Reserved[_token][_tokenId] = false; erc721TotalReserved[_token]--; } - /// @notice Increases reserved ERC1155 amount for (_token, _tokenId). Only REWARDS_MANAGER_ROLE. - function increaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + function _increaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) internal { erc1155ReservedAmounts[_token][_tokenId] += _amount; erc1155TotalReserved[_token] += _amount; } - /// @notice Decreases reserved ERC1155 amount. Only REWARDS_MANAGER_ROLE. Reverts if would go below zero. - function decreaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { + function _decreaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) internal { erc1155ReservedAmounts[_token][_tokenId] -= _amount; erc1155TotalReserved[_token] -= _amount; } - /// @notice Registers a new reward token (item) with given id and reward definition. Only REWARDS_MANAGER_ROLE. Id must not exist. - function addRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) external onlyRole(REWARDS_MANAGER_ROLE) { + 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 Updates reward definition for an existing reward token (e.g. URI). Only REWARDS_MANAGER_ROLE. Does not extend ERC721 reward ids. - function updateRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) external onlyRole(REWARDS_MANAGER_ROLE) { - if (!tokenExists[_tokenId]) revert TokenNotWhitelisted(); + function _updateRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) internal { + if (!tokenExists[_tokenId]) revert TokenNotExist(); tokenRewards[_tokenId] = _rewardToken; } - /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155 on this server. Only REWARDS_MANAGER_ROLE. + function _increaseCurrentSupply(uint256 _tokenId, uint256 _amount) internal { + currentRewardSupply[_tokenId] += _amount; + } + + function _incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex, uint256 _delta) internal { + erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex] += _delta; + } + + + /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155/ETH on this server. Only REWARDS_ROUTER_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 onlyRole(REWARDS_MANAGER_ROLE) { + function increaseRewardSupply(uint256 _tokenId, uint256 _additionalSupply) external payable onlyRole(REWARDS_ROUTER_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; @@ -553,11 +571,11 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU tokenRewards[_tokenId] = rewardToken; } - /// @notice Reduces max supply for a reward token; releases proportional ERC20/ERC1155 reservations. Only REWARDS_MANAGER_ROLE. + /// @notice Reduces max supply for a reward token; releases proportional ERC20/ERC1155 reservations. Only REWARDS_ROUTER_ROLE. /// @dev New max supply must not be below currentRewardSupply. ERC721: only maxSupply is reduced (reserved NFT ids unchanged). /// @param _tokenId Reward token id. /// @param _reduceBy Amount to subtract from max supply (must be > 0). - function reduceRewardSupply(uint256 _tokenId, uint256 _reduceBy) external onlyRole(REWARDS_MANAGER_ROLE) { + function reduceRewardSupply(uint256 _tokenId, uint256 _reduceBy) external onlyRole(REWARDS_ROUTER_ROLE) { if (!tokenExists[_tokenId]) revert TokenNotExist(); if (_reduceBy == 0) revert InvalidAmount(); @@ -568,7 +586,9 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU for (uint256 i = 0; i < rewardToken.rewards.length; i++) { LibItems.Reward memory r = rewardToken.rewards[i]; - if (r.rewardType == LibItems.RewardType.ERC20) { + 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) { @@ -583,34 +603,137 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU tokenRewards[_tokenId] = rewardToken; } - /// @notice Pauses or unpauses minting for a reward token. Only REWARDS_MANAGER_ROLE. - function setTokenMintPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Pauses or unpauses minting for a reward token. Only REWARDS_ROUTER_ROLE. + function setTokenMintPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_ROUTER_ROLE) { isTokenMintPaused[_tokenId] = _isPaused; } - /// @notice Pauses or unpauses claiming rewards for a reward token. Only REWARDS_MANAGER_ROLE. - function setClaimRewardPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_MANAGER_ROLE) { + /// @notice Pauses or unpauses claiming rewards for a reward token. Only REWARDS_ROUTER_ROLE. + function setClaimRewardPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_ROUTER_ROLE) { isClaimRewardPaused[_tokenId] = _isPaused; } - /// @notice Increments current minted supply for a reward token (e.g. after mint). Only REWARDS_MANAGER_ROLE. - function increaseCurrentSupply(uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { - currentRewardSupply[_tokenId] += _amount; + /// @notice Sets a user nonce as used/unused (replay protection for mint/claim). Only REWARDS_ROUTER_ROLE. + function setUserNonce(address _user, uint256 _nonce, bool _used) external onlyRole(REWARDS_ROUTER_ROLE) { + userNonces[_user][_nonce] = _used; } - /// @notice Decrements current supply (e.g. burn or correction). Only REWARDS_MANAGER_ROLE. - function decreaseCurrentSupply(uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_MANAGER_ROLE) { - currentRewardSupply[_tokenId] -= _amount; + /// @notice Advances the ERC721 distribution index for a reward slot by _delta (e.g. after distributing rewardAmount NFTs). Only REWARDS_ROUTER_ROLE. + function incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex, uint256 _delta) external onlyRole(REWARDS_ROUTER_ROLE) { + _incrementERC721RewardIndex(_rewardTokenId, _rewardIndex, _delta); } - /// @notice Sets a user nonce as used/unused (replay protection for mint/claim). Only REWARDS_MANAGER_ROLE. - function setUserNonce(address _user, uint256 _nonce, bool _used) external onlyRole(REWARDS_MANAGER_ROLE) { - userNonces[_user][_nonce] = _used; + /*////////////////////////////////////////////////////////////// + VALIDATE & CREATE / UPDATE URI / CLAIM (REWARDS_ROUTER_ROLE) + //////////////////////////////////////////////////////////////*/ + + /// @notice Validates and creates a new reward token; reserves ERC20/721/1155 and ETH on this server. Only REWARDS_ROUTER_ROLE. Send ETH if token has ETHER rewards. + function createTokenAndReserveRewards(LibItems.RewardToken calldata token) external payable onlyRole(REWARDS_ROUTER_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(); + _increaseERC20Reserved(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++) { + _reserveERC721(r.rewardTokenAddress, r.rewardTokenIds[j]); + } + } 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(); + _increaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, totalAmount); + } + } + + LibItems.RewardToken memory tokenMem = token; + _addRewardToken(token.tokenId, tokenMem); } - /// @notice Advances the ERC721 distribution index for a reward slot by _delta (e.g. after distributing rewardAmount NFTs). Only REWARDS_MANAGER_ROLE. - function incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex, uint256 _delta) external onlyRole(REWARDS_MANAGER_ROLE) { - erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex] += _delta; + /// @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) external onlyRole(REWARDS_ROUTER_ROLE) { + if (to == address(0)) revert AddressIsZero(); + if (!tokenExists[tokenId]) revert TokenNotExist(); + if (isClaimRewardPaused[tokenId]) revert ClaimRewardPaused(); + + if (currentRewardSupply[tokenId] + 1 > tokenRewards[tokenId].maxSupply) revert ExceedMaxSupply(); + + _distributeReward(to, tokenId); + + currentRewardSupply[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); + _decreaseERC20Reserved(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]; + _releaseERC721(r.rewardTokenAddress, nftId); + IERC721(r.rewardTokenAddress).safeTransferFrom(address(this), to, nftId); + } + _incrementERC721RewardIndex(rewardTokenId, i, r.rewardAmount); + } else if (r.rewardType == LibItems.RewardType.ERC1155) { + _decreaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, r.rewardAmount); + IERC1155(r.rewardTokenAddress).safeTransferFrom(address(this), to, r.rewardTokenId, r.rewardAmount, ""); + } + } } /*////////////////////////////////////////////////////////////// @@ -805,4 +928,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU function supportsInterface(bytes4 interfaceId) public view virtual override(AccessControlUpgradeable, ERC1155HolderUpgradeable) returns (bool) { return super.supportsInterface(interfaceId); } + + /// @notice Accepts ETH sent to this server's treasury (e.g. for topping up unreserved ETH). + receive() external payable {} } diff --git a/scripts/deployRewardsManager.ts b/scripts/deployRewardsManager.ts index c535caf..999ed81 100644 --- a/scripts/deployRewardsManager.ts +++ b/scripts/deployRewardsManager.ts @@ -1,14 +1,13 @@ import { ethers, upgrades } from 'hardhat'; /** - * Deploy RewardsManager (one manager, many servers), beacon and RewardsFactory. + * Deploy RewardsRouter (one router, many servers) and beacon. * * 1. Deploy RewardsServer implementation (for beacon) - * 2. Deploy RewardsManager (UUPS proxy), initialize with (dev, manager) + * 2. Deploy RewardsRouter (UUPS proxy), initialize with (dev, manager) * 3. initializeBeacons(rewardsServerImpl) - * 4. Deploy RewardsFactory(manager), setBeacons, grant FACTORY_ROLE to factory on manager (required for deployServer to succeed) * - * After this, anyone can call factory.deployServer(serverId) to create a server. + * After this, an account with MANAGER_ROLE can call router.deployServer(serverId, serverAdmin) to create a server. * * Usage: * pnpm hardhat run scripts/deployRewardsManager.ts --network hardhat @@ -19,7 +18,7 @@ async function main() { const [deployer] = await ethers.getSigners(); console.log('========================================'); - console.log('RewardsManager Deployment'); + console.log('RewardsRouter Deployment'); console.log('========================================'); console.log('Deployer:', deployer.address); console.log('========================================\n'); @@ -35,45 +34,32 @@ async function main() { const rewardsServerImplAddress = await rewardsServerImpl.getAddress(); console.log(' RewardsServer impl:', rewardsServerImplAddress); - // 2. Deploy RewardsManager (UUPS proxy) - console.log('\n2. Deploying RewardsManager (UUPS proxy)...'); - const RewardsManager = await ethers.getContractFactory('RewardsManager'); - const manager = await upgrades.deployProxy( - RewardsManager, + // 2. Deploy RewardsRouter (UUPS proxy) + console.log('\n2. Deploying RewardsRouter (UUPS proxy)...'); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const router = await upgrades.deployProxy( + RewardsRouter, [devWallet, managerWallet], { kind: 'uups', initializer: 'initialize' } ); - await manager.waitForDeployment(); - const managerAddress = await manager.getAddress(); - const managerImpl = await upgrades.erc1967.getImplementationAddress(managerAddress); - console.log(' RewardsManager proxy:', managerAddress); - console.log(' RewardsManager impl:', managerImpl); + 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); // 3. Initialize beacon console.log('\n3. Initializing rewards server beacon...'); - let tx = await manager.initializeBeacons(rewardsServerImplAddress); + const tx = await router.initializeBeacons(rewardsServerImplAddress); await tx.wait(); console.log(' Beacon initialized.'); - // 4. Deploy RewardsFactory and wire to manager - console.log('\n4. Deploying RewardsFactory...'); - const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); - const factory = await RewardsFactory.deploy(managerAddress); - await factory.waitForDeployment(); - const factoryAddress = await factory.getAddress(); - tx = await factory.setBeacons(await manager.treasuryBeacon()); - await tx.wait(); - tx = await manager.grantRole(await manager.FACTORY_ROLE(), factoryAddress); - await tx.wait(); - console.log(' RewardsFactory:', factoryAddress); - console.log('\n========================================'); console.log('Deployment complete.'); console.log('========================================'); - console.log('RewardsManager:', managerAddress); - console.log('RewardsFactory:', factoryAddress); - console.log('To create a server: factory.deployServer(serverId)'); - console.log(' e.g. serverId = ethers.keccak256(ethers.toUtf8Bytes("my-server"))'); + 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() diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts index 4eef44c..a06ac62 100644 --- a/test/rewardsManager.test.ts +++ b/test/rewardsManager.test.ts @@ -2,15 +2,15 @@ import { expect } from 'chai'; import { ethers, upgrades } from 'hardhat'; import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; -describe('RewardsManager', function () { - const SERVER_ID = ethers.keccak256(ethers.toUtf8Bytes('server-1')); +describe('RewardsRouter', function () { + const SERVER_ID = 1; async function deployRewardsManagerFixture() { const [devWallet, managerWallet, user1, user2] = await ethers.getSigners(); - const RewardsManager = await ethers.getContractFactory('RewardsManager'); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); const manager = await upgrades.deployProxy( - RewardsManager, + RewardsRouter, [devWallet.address, managerWallet.address], { kind: 'uups', initializer: 'initialize' } ); @@ -22,15 +22,8 @@ describe('RewardsManager', function () { await manager.connect(devWallet).initializeBeacons(await rewardsServerImpl.getAddress()); - const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); - const factory = await RewardsFactory.deploy(await manager.getAddress()); - await factory.waitForDeployment(); - await factory.setBeacons(await manager.treasuryBeacon()); - await manager.connect(devWallet).grantRole(await manager.FACTORY_ROLE(), await factory.getAddress()); - return { manager, - factory, devWallet, managerWallet, user1, @@ -40,8 +33,11 @@ describe('RewardsManager', function () { async function deployWithServerAndTokenFixture() { const base = await loadFixture(deployRewardsManagerFixture); - await base.factory.connect(base.user1).deployServer(SERVER_ID); - await base.manager.connect(base.user1).setServerSigner(SERVER_ID, base.managerWallet.address, true); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + // devWallet is initial server admin; use manager helper to grant SERVER_ADMIN_ROLE to managerWallet + await base.manager.connect(base.devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, base.managerWallet.address); + await base.manager.connect(base.managerWallet).addWhitelistSigner(SERVER_ID, base.managerWallet.address); const MockERC20 = await ethers.getContractFactory('MockERC20'); const mockERC20 = await MockERC20.deploy('Mock', 'M'); @@ -63,94 +59,50 @@ describe('RewardsManager', function () { describe('deployServer', function () { it('should deploy a server with RewardsServer', async function () { - const { manager, factory, user1 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(user1).deployServer(SERVER_ID); + 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, factory, user1 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(user1).deployServer(SERVER_ID); - await expect(factory.connect(user1).deployServer(SERVER_ID)) + 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, factory, user1 } = await loadFixture(deployRewardsManagerFixture); - await expect(factory.connect(user1).deployServer(ethers.ZeroHash)) + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + await expect(manager.connect(managerWallet).deployServer(0, devWallet.address)) .to.be.revertedWithCustomError(manager, 'InvalidServerId'); }); }); - describe('RewardsFactory ownership', function () { - it('owner can call transferOwnership and pendingOwner is set', async function () { - const { factory, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); - expect(await factory.owner()).to.equal(devWallet.address); - await factory.connect(devWallet).transferOwnership(managerWallet.address); - expect(await factory.pendingOwner()).to.equal(managerWallet.address); - expect(await factory.owner()).to.equal(devWallet.address); - }); - - it('non-owner cannot call transferOwnership or setBeacons', async function () { - const { manager, factory, user1 } = await loadFixture(deployRewardsManagerFixture); - await expect( - factory.connect(user1).transferOwnership(user1.address) - ).to.be.revertedWithCustomError(factory, 'Unauthorized'); - const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); - const newFactory = await RewardsFactory.deploy(await manager.getAddress()); - await newFactory.waitForDeployment(); - await expect( - newFactory.connect(user1).setBeacons(await factory.treasuryBeacon()) - ).to.be.revertedWithCustomError(newFactory, 'Unauthorized'); - }); - - it('only pendingOwner can call acceptOwnership; owner and pendingOwner updated', async function () { - const { factory, devWallet, managerWallet, user1 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(devWallet).transferOwnership(managerWallet.address); - await expect(factory.connect(user1).acceptOwnership()).to.be.revertedWithCustomError(factory, 'Unauthorized'); - await factory.connect(managerWallet).acceptOwnership(); - expect(await factory.owner()).to.equal(managerWallet.address); - expect(await factory.pendingOwner()).to.equal(ethers.ZeroAddress); - }); - - it('after transfer old owner cannot call setBeacons', async function () { - const { manager, factory, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); - const beaconAddr = await factory.treasuryBeacon(); - const RewardsFactory = await ethers.getContractFactory('RewardsFactory'); - const factory2 = await RewardsFactory.deploy(await manager.getAddress()); - await factory2.waitForDeployment(); - expect(await factory2.owner()).to.equal(devWallet.address); - await factory2.connect(devWallet).transferOwnership(managerWallet.address); - await factory2.connect(managerWallet).acceptOwnership(); - expect(await factory2.owner()).to.equal(managerWallet.address); - await expect(factory2.connect(devWallet).setBeacons(beaconAddr)).to.be.revertedWithCustomError( - factory2, - 'Unauthorized' - ); - }); - }); + // RewardsFactory ownership tests removed: deployment is now handled directly by RewardsManager.deployServer. describe('server admin and signers', function () { - it('server admin can set signer and withdrawer', async function () { - const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(user1).deployServer(SERVER_ID); - - await manager.connect(user1).setServerSigner(SERVER_ID, user2.address, true); + 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 SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + // devWallet (initial server admin) grants SERVER_ADMIN_ROLE to user1 via manager helper + await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); + + await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.true; - - await manager.connect(user1).setServerWithdrawer(SERVER_ID, user2.address, true); - expect(await manager.isServerWithdrawer(SERVER_ID, user2.address)).to.be.true; }); it('non-admin cannot set signer', async function () { - const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(user1).deployServer(SERVER_ID); + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); const serverAddr = await manager.getServer(SERVER_ID); const server = await ethers.getContractAt('RewardsServer', serverAddr); await expect( - manager.connect(user2).setServerSigner(SERVER_ID, user2.address, true) + manager.connect(user2).addWhitelistSigner(SERVER_ID, user2.address) ).to.be.revertedWithCustomError(server, 'UnauthorizedServerAdmin'); }); }); @@ -158,7 +110,7 @@ describe('RewardsManager', function () { /** Build claim data and signature for claim(). Signer must be set as server signer. */ async function buildClaimDataAndSignature( manager: Awaited>, - serverId: string, + serverId: number, signer: Awaited>[0], beneficiary: string, tokenIds: number[], @@ -173,7 +125,7 @@ describe('RewardsManager', function () { ); const messageHash = ethers.keccak256( ethers.AbiCoder.defaultAbiCoder().encode( - ['address', 'uint256', 'bytes32', 'address', 'uint256', 'uint256[]', 'uint256'], + ['address', 'uint256', 'uint8', 'address', 'uint256', 'uint256[]', 'uint256'], [managerAddress, chainId, serverId, beneficiary, expiration, tokenIds, nonce] ) ); @@ -272,7 +224,7 @@ describe('RewardsManager', function () { expect(after_ - (before - gasCost)).to.equal(rewardAmount); }); - it('MANAGER_ROLE can withdraw unreserved ETHER from manager', async function () { + it('MANAGER_ROLE can withdraw unreserved ETHER from server treasury', async function () { const { manager, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 3; const rewardToken = { @@ -304,8 +256,9 @@ describe('RewardsManager', function () { ); await manager.connect(user1).claim(SERVER_ID, data, 0, signature); const extraEth = ethers.parseEther('0.3'); + const serverTreasury = await manager.getServer(SERVER_ID); await managerWallet.sendTransaction({ - to: await manager.getAddress(), + to: serverTreasury, value: extraEth, }); const before = await ethers.provider.getBalance(user1.address); @@ -507,24 +460,29 @@ describe('RewardsManager', function () { }); describe('access control', function () { - it('non-MANAGER cannot call whitelistToken', async function () { - const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(user1).deployServer(SERVER_ID); + 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 SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); const MockERC20 = await ethers.getContractFactory('MockERC20'); const mockERC20 = await MockERC20.deploy('M', 'M'); await mockERC20.waitForDeployment(); await expect( manager.connect(user2).whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1) - ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + ).to.be.revertedWithCustomError(manager, 'UnauthorizedServerAdmin'); }); - it('non-MANAGER cannot call createTokenAndDepositRewards', async function () { + it('non-server-admin cannot call createTokenAndDepositRewards', async function () { const base = await loadFixture(deployRewardsManagerFixture); - await base.factory.connect(base.user1).deployServer(SERVER_ID); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await base.manager.connect(base.devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, base.user1.address); const MockERC20 = await ethers.getContractFactory('MockERC20'); const mockERC20 = await MockERC20.deploy('M', 'M'); await mockERC20.waitForDeployment(); - await base.manager.connect(base.managerWallet).whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1); + // whitelist by server admin + await base.manager.connect(base.user1).whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1); const rewardToken = { tokenId: 1, tokenUri: 'u', @@ -540,26 +498,25 @@ describe('RewardsManager', function () { ], }; await expect( - base.manager.connect(base.user1).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }) - ).to.be.revertedWithCustomError(base.manager, 'AccessControlUnauthorizedAccount'); + base.manager.connect(base.user2).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }) + ).to.be.revertedWithCustomError(base.manager, 'UnauthorizedServerAdmin'); }); - it('non-MANAGER cannot call withdrawAssets', async function () { + it('non-server-admin cannot call withdrawAssets', async function () { const base = await loadFixture(deployRewardsManagerFixture); - await base.factory.connect(base.user1).deployServer(SERVER_ID); + await base.manager.connect(base.managerWallet).deployServer(SERVER_ID, base.devWallet.address); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await base.manager.connect(base.devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, base.user1.address); await expect( base.manager - .connect(base.user1) + .connect(base.user2) .withdrawAssets(SERVER_ID, 1, base.user2.address, ethers.ZeroAddress, [], []) - ).to.be.revertedWithCustomError(base.manager, 'AccessControlUnauthorizedAccount'); + ).to.be.revertedWithCustomError(base.manager, 'UnauthorizedServerAdmin'); }); it('non-MANAGER cannot call pause', async function () { const { manager, user1 } = await loadFixture(deployRewardsManagerFixture); - await expect(manager.connect(user1).pause()).to.be.revertedWithCustomError( - manager, - 'AccessControlUnauthorizedAccount' - ); + await expect(manager.connect(user1).pause()).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); }); it('only FACTORY_ROLE can call registerServer', async function () { @@ -567,9 +524,8 @@ describe('RewardsManager', function () { const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); const impl = await RewardsServerImpl.deploy(); await impl.waitForDeployment(); - const serverId2 = ethers.keccak256(ethers.toUtf8Bytes('server-2')); await expect( - manager.connect(user1).registerServer(serverId2, await impl.getAddress()) + manager.connect(user1).registerServer(2, await impl.getAddress()) ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); }); }); @@ -603,40 +559,19 @@ describe('RewardsManager', function () { }); }); - describe('decodeData', function () { - it('decodes claim data for debugging', async function () { - const { manager } = await loadFixture(deployRewardsManagerFixture); - const chainId = (await ethers.provider.getNetwork()).chainId; - const managerAddress = await manager.getAddress(); - const beneficiary = '0x0000000000000000000000000000000000000001'; - const expiration = 999999; - const tokenIds = [1, 2]; - const data = ethers.AbiCoder.defaultAbiCoder().encode( - ['address', 'uint256', 'address', 'uint256', 'uint256[]'], - [managerAddress, chainId, beneficiary, expiration, tokenIds] - ); - const decoded = await manager.decodeData(data); - expect(decoded.contractAddress).to.equal(managerAddress); - expect(decoded.chainId).to.equal(chainId); - expect(decoded.beneficiary).to.equal(beneficiary); - expect(decoded.expiration).to.equal(expiration); - expect(decoded.tokenIds.length).to.equal(2); - expect(decoded.tokenIds[0]).to.equal(1); - expect(decoded.tokenIds[1]).to.equal(2); - }); - }); - describe('getServerSigners', function () { it('returns empty when no signers added', async function () { - const { manager, factory } = await loadFixture(deployRewardsManagerFixture); - await factory.connect((await ethers.getSigners())[2]).deployServer(SERVER_ID); + 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 addWhitelistSigner', async function () { - const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(user1).deployServer(SERVER_ID); + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); let list = await manager.getServerSigners(SERVER_ID); expect(list.length).to.equal(0); await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); @@ -655,10 +590,38 @@ describe('RewardsManager', function () { }); }); + 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 SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, managerWallet.address); + await manager.connect(managerWallet).addWhitelistSigner(SERVER_ID, managerWallet.address); + 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: ethers.parseEther('2') }); + const expiration = Math.floor(Date.now() / 1000) + 3600; + const { data, signature } = await buildClaimDataAndSignature(manager, SERVER_ID, managerWallet, user1.address, [tokenId], expiration, 0); + const beforeBalance = await ethers.provider.getBalance(user1.address); + const tx = await manager.connect(user1).claim(SERVER_ID, data, 0, 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')); + }); + }); + describe('addWhitelistSigner and removeWhitelistSigner', function () { it('addWhitelistSigner enables signer, removeWhitelistSigner disables', async function () { - const { manager, factory, user1, user2 } = await loadFixture(deployRewardsManagerFixture); - await factory.connect(user1).deployServer(SERVER_ID); + const { manager, devWallet, managerWallet, user1, user2 } = await loadFixture(deployRewardsManagerFixture); + await manager.connect(managerWallet).deployServer(SERVER_ID, devWallet.address); + const SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); + await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.false; await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.true; @@ -696,7 +659,7 @@ describe('RewardsManager', function () { expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(7); }); - it('non-MANAGER cannot call reduceRewardSupply', async function () { + it('non-server-admin cannot call reduceRewardSupply', async function () { const { manager, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 1; const rewardToken = { @@ -713,10 +676,11 @@ describe('RewardsManager', function () { }, ], }; + // Server admin (managerWallet) creates the reward await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); await expect( manager.connect(user1).reduceRewardSupply(SERVER_ID, tokenId, 2) - ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + ).to.be.revertedWithCustomError(manager, 'UnauthorizedServerAdmin'); }); }); }); From 166315989a04ad0b649ea570408a0ec6b2a2539e Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Wed, 25 Feb 2026 12:18:42 -0300 Subject: [PATCH 6/9] Refactor: Split router from server logic --- .../upgradeables/soulbounds/RewardsRouter.sol | 536 ++------------- .../upgradeables/soulbounds/RewardsServer.sol | 636 ++++++++++-------- test/rewardsManager.test.ts | 552 ++++++++++----- 3 files changed, 787 insertions(+), 937 deletions(-) diff --git a/contracts/upgradeables/soulbounds/RewardsRouter.sol b/contracts/upgradeables/soulbounds/RewardsRouter.sol index 90cf96d..5822bcc 100644 --- a/contracts/upgradeables/soulbounds/RewardsRouter.sol +++ b/contracts/upgradeables/soulbounds/RewardsRouter.sol @@ -4,8 +4,6 @@ pragma solidity ^0.8.28; // @author Summon.xyz Team - https://summon.xyz // @contributors: [ @ogarciarevett, @karacurt] -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; @@ -21,10 +19,6 @@ import { 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 { - MessageHashUtils -} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; -import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { LibItems } from "../../libraries/LibItems.sol"; import { RewardsServer } from "./RewardsServer.sol"; @@ -41,7 +35,6 @@ contract RewardsRouter is ReentrancyGuardUpgradeable, UUPSUpgradeable { - using SafeERC20 for IERC20; /*////////////////////////////////////////////////////////////// ERRORS @@ -52,36 +45,16 @@ contract RewardsRouter is error InvalidServerId(); error BeaconNotInitialized(); error BeaconsAlreadyInitialized(); - error InvalidSignature(); - error UnauthorizedServerAdmin(); - error InsufficientBalance(); - error InvalidAmount(); - error InvalidInput(); - error NonceAlreadyUsed(); - error TokenNotExist(); - error ExceedMaxSupply(); - error MintPaused(); - error ClaimRewardPaused(); - error DupTokenId(); - error TokenNotWhitelisted(); - error InsufficientTreasuryBalance(); - error TransferFailed(); - error InvalidLength(); - + /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ - uint256 public constant MAX_CLAIM_TOKEN_IDS = 50; 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"); - /*////////////////////////////////////////////////////////////// - STRUCTS - //////////////////////////////////////////////////////////////*/ - /*////////////////////////////////////////////////////////////// STATE //////////////////////////////////////////////////////////////*/ @@ -90,7 +63,7 @@ contract RewardsRouter is mapping(uint8 => address) private servers; // Beacons (single implementation per type, upgradeable for all servers) - address public treasuryBeacon; + address public serverBeacon; uint256[44] private __gap; @@ -100,69 +73,6 @@ contract RewardsRouter is event ServerDeployed(uint8 indexed serverId, address treasury); - event ServerSignerUpdated( - uint8 indexed serverId, - address indexed signer, - bool isActive - ); - - event ServerAdminTransferred( - uint8 indexed serverId, - address indexed oldAdmin, - address indexed newAdmin - ); - - event TokenAdded(uint8 indexed serverId, uint256 indexed tokenId); - event Minted( - uint8 indexed serverId, - address indexed to, - uint256 indexed tokenId, - uint256 amount, - bool soulbound - ); - event Claimed( - uint8 indexed serverId, - address indexed to, - uint256 indexed tokenId - ); - event TokenMintPausedUpdated( - uint8 indexed serverId, - uint256 indexed tokenId, - bool isPaused - ); - event ClaimRewardPausedUpdated( - uint8 indexed serverId, - uint256 indexed tokenId, - bool isPaused - ); - event RewardSupplyChanged( - uint8 indexed serverId, - uint256 indexed tokenId, - uint256 oldSupply, - uint256 newSupply - ); - event TokenURIChanged( - uint8 indexed serverId, - uint256 indexed tokenId, - string newUri - ); - event AssetsWithdrawn( - uint8 indexed serverId, - LibItems.RewardType rewardType, - address indexed to, - uint256 amount - ); - event ServerRoleGranted( - uint8 indexed serverId, - bytes32 indexed role, - address indexed to - ); - event ServerRoleRevoked( - uint8 indexed serverId, - bytes32 indexed role, - address indexed from - ); - /*////////////////////////////////////////////////////////////// INITIALIZER //////////////////////////////////////////////////////////////*/ @@ -200,10 +110,18 @@ contract RewardsRouter is address newImplementation ) internal override onlyRole(UPGRADER_ROLE) {} - modifier onlyServerAdmin(uint8 serverId) { - address server = _getServer(serverId); - if (!RewardsServer(payable(server)).isServerAdmin(msg.sender)) revert UnauthorizedServerAdmin(); - _; + /*////////////////////////////////////////////////////////////// + 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(); } /*////////////////////////////////////////////////////////////// @@ -211,358 +129,52 @@ contract RewardsRouter is //////////////////////////////////////////////////////////////*/ /// @notice Sets the RewardsServer implementation beacon. Callable once by DEV_CONFIG_ROLE. - /// @param _treasuryImplementation Implementation contract for RewardsServer (BeaconProxy targets this). - function initializeBeacons(address _treasuryImplementation) external onlyRole(DEV_CONFIG_ROLE) { - if (address(_treasuryImplementation) == address(0)) { + /// @param _serverImplementation Implementation contract for RewardsServer (BeaconProxy targets this). + function initializeBeacons(address _serverImplementation) external onlyRole(DEV_CONFIG_ROLE) { + if (address(_serverImplementation) == address(0)) { revert AddressIsZero(); } - if (address(treasuryBeacon) != address(0)) { + if (serverBeacon != address(0)) { revert BeaconsAlreadyInitialized(); } - treasuryBeacon = address(new UpgradeableBeacon( - _treasuryImplementation, + serverBeacon = address(new UpgradeableBeacon( + _serverImplementation, address(this) )); } - /*////////////////////////////////////////////////////////////// - SERVER MANAGEMENT - //////////////////////////////////////////////////////////////*/ - - function _getServer(uint8 serverId) internal view returns (address treasury) { - treasury = servers[serverId]; - if (treasury == address(0)) { - revert ServerDoesNotExist(); - } - } - - /// @notice Registers a new server (RewardsServer proxy) for the given serverId. Only FACTORY_ROLE (RewardsFactory). - /// @param serverId Unique server identifier (small uint8). - /// @param server Address of the deployed RewardsServer proxy. - function registerServer(uint8 serverId, address server) external onlyRole(MANAGER_ROLE) { - if (serverId == 0) revert InvalidServerId(); - if (servers[serverId] != address(0)) revert ServerAlreadyExists(); - if (server == address(0)) revert AddressIsZero(); - - servers[serverId] = server; - emit ServerDeployed(serverId, server); - } - - /// @notice Deploys and registers a new RewardsServer treasury for the given serverId. Only FACTORY_ROLE. + /// @notice Deploys and registers a new RewardsServer treasury for the given serverId. Only MANAGER_ROLE. /// @dev Caller becomes SERVER_ADMIN_ROLE on the new server. /// @param serverId Unique server identifier (small uint8). - function deployServer(uint8 serverId, address serverAdmin) external onlyRole(MANAGER_ROLE) returns (address server) { + 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 (address(treasuryBeacon) == address(0)) revert BeaconNotInitialized(); + if (serverBeacon == address(0)) revert BeaconNotInitialized(); bytes memory initData = abi.encodeWithSelector( RewardsServer.initialize.selector, - address(this), - address(this), - serverAdmin + serverAdmin, + serverId ); - server = address(new BeaconProxy(address(treasuryBeacon), initData)); + server = address(new BeaconProxy(serverBeacon, initData)); servers[serverId] = server; emit ServerDeployed(serverId, server); } - /// @notice Grants a role to an address on the server. Caller must be current SERVER_ADMIN_ROLE on the server. - function grantServerRole(uint8 serverId, bytes32 role, address to) external onlyServerAdmin(serverId) { - if (to == address(0)) revert AddressIsZero(); - address server = _getServer(serverId); - RewardsServer(payable(server)).grantRole(role, to); - emit ServerRoleGranted(serverId, role, to); - } - - /// @notice Revokes a role from an address on the server. Caller must be current SERVER_ADMIN_ROLE on the server. - function revokeServerRole(uint8 serverId, bytes32 role, address from) external onlyServerAdmin(serverId) { - if (from == address(0)) revert AddressIsZero(); - address server = _getServer(serverId); - RewardsServer(payable(server)).revokeRole(role, from); - emit ServerRoleRevoked(serverId, role, from); - } - - /// @notice Returns the RewardsServer (treasury) address for a server. - function getServer(uint8 serverId) external view returns (address server) { - return _getServer(serverId); - } - - /*////////////////////////////////////////////////////////////// - PER-SERVER ACCESS CONTROL - //////////////////////////////////////////////////////////////*/ - - /// @notice Adds a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. - function addWhitelistSigner(uint8 serverId, address signer) external onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer(payable(server)).setSigner(signer, true); - emit ServerSignerUpdated(serverId, signer, true); - } - - /// @notice Removes a claim signer for the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. - function removeWhitelistSigner(uint8 serverId, address signer) external { - address server = _getServer(serverId); - RewardsServer(payable(server)).setSigner(signer, false); - emit ServerSignerUpdated(serverId, signer, false); - } - - /// @notice Returns whether the address is an active signer for the server. - function isServerSigner(uint8 serverId, address signer) external view returns (bool) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).isSigner(signer); - } - - /// @notice Returns list of all active signer addresses for the server (rewards-get-whitelist-signers). - function getServerSigners(uint8 serverId) external view returns (address[] memory) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getSigners(); - } - - /*////////////////////////////////////////////////////////////// - SIGNATURE VERIFICATION - //////////////////////////////////////////////////////////////*/ - - /** - * @dev Internal helper to verify a tenant-scoped signature. - * - * Message format (hashed then wrapped in EIP-191 prefix): - * keccak256(abi.encodePacked(contractAddress, chainId, serverId, beneficiary, expiration, tokenIds, nonce)) - */ - function _verifyServerSignature( - uint8 serverId, - address beneficiary, - uint256 expiration, - uint256[] memory tokenIds, - uint256 nonce, - bytes calldata signature - ) internal view returns (address) { - uint256 currentChainId = block.chainid; - bytes32 message = keccak256( - abi.encode( - address(this), - currentChainId, - serverId, - beneficiary, - expiration, - tokenIds, - nonce - ) - ); - bytes32 hash = MessageHashUtils.toEthSignedMessageHash(message); - address signer = ECDSA.recover(hash, signature); - - address server = _getServer(serverId); - if (!RewardsServer(payable(server)).isSigner(signer)) { - revert InvalidSignature(); - } - - if (block.timestamp >= expiration) { - revert InvalidSignature(); - } - - return signer; - } - - /*////////////////////////////////////////////////////////////// - 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(); - } - - /*////////////////////////////////////////////////////////////// - TREASURY MANAGEMENT (PER TENANT) - //////////////////////////////////////////////////////////////*/ - - /// @notice Adds a token to the server whitelist. Caller must be SERVER_ADMIN_ROLE on the server. - function whitelistToken( - uint8 serverId, - address token, - LibItems.RewardType rewardType - ) external onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - serverContract.whitelistToken(token, rewardType); - } - - /// @notice Removes a token from the server whitelist (fails if token has reserves). Caller must be SERVER_ADMIN_ROLE on the server. - function removeTokenFromWhitelist( - uint8 serverId, - address token - ) external onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - serverContract.removeTokenFromWhitelist(token); - } - - /// @notice Deposits ERC20 from msg.sender into the server treasury. Token must be whitelisted. Reentrancy-protected. - function depositToTreasury( - uint8 serverId, - address token, - uint256 amount - ) external nonReentrant { - address server = _getServer(serverId); - RewardsServer(payable(server)).depositToTreasury(token, amount, msg.sender); - } - - /*////////////////////////////////////////////////////////////// - REWARD TOKEN CREATION AND SUPPLY (PER TENANT) - //////////////////////////////////////////////////////////////*/ - - /// @notice Creates a new reward token on the server and reserves/deposits rewards. Send ETH if token has ETHER rewards (forwarded to server). Caller must be SERVER_ADMIN_ROLE on the server. - /// @param serverId Server id. - /// @param token Reward token definition (tokenId, maxSupply, rewards, tokenUri). ERC721 rewards require exact rewardTokenIds length = rewardAmount * maxSupply. - function createTokenAndDepositRewards( - uint8 serverId, - LibItems.RewardToken calldata token - ) external payable nonReentrant onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - serverContract.createTokenAndReserveRewards{ value: msg.value }(token); - emit TokenAdded(serverId, token.tokenId); - } - - /// @notice Pauses or unpauses minting for a reward token. Caller must be SERVER_ADMIN_ROLE on the server. - function updateTokenMintPaused( - uint8 serverId, - uint256 tokenId, - bool isPaused - ) external onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - serverContract.setTokenMintPaused(tokenId, isPaused); - emit TokenMintPausedUpdated(serverId, tokenId, isPaused); - } - - /// @notice Pauses or unpauses claiming for a reward token. Caller must be SERVER_ADMIN_ROLE on the server. - function updateClaimRewardPaused( - uint8 serverId, - uint256 tokenId, - bool isPaused - ) external onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - serverContract.setClaimRewardPaused(tokenId, isPaused); - emit ClaimRewardPausedUpdated(serverId, tokenId, isPaused); - } - - /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155/ETH on server. Caller must be SERVER_ADMIN_ROLE on the server. Send ETH if token has ETHER rewards (forwarded to server). - /// @param serverId Server id. - /// @param tokenId Reward token id. - /// @param additionalSupply Amount to add. Not supported for ERC721-backed rewards (create a new token instead). - function increaseRewardSupply( - uint8 serverId, - uint256 tokenId, - uint256 additionalSupply - ) external payable onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - if (!serverContract.isTokenExists(tokenId)) revert TokenNotExist(); - if (additionalSupply == 0) revert InvalidAmount(); - uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; - serverContract.increaseRewardSupply{ value: msg.value }(tokenId, additionalSupply); - emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply + additionalSupply); - } - - /// @notice Reduces max supply of a reward token on the server. Caller must be SERVER_ADMIN_ROLE on the server. For admin/DB parity. - function reduceRewardSupply( - uint8 serverId, - uint256 tokenId, - uint256 reduceBy - ) external onlyServerAdmin(serverId) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - uint256 oldSupply = serverContract.getRewardToken(tokenId).maxSupply; - serverContract.reduceRewardSupply(tokenId, reduceBy); - emit RewardSupplyChanged(serverId, tokenId, oldSupply, oldSupply - reduceBy); - } - - /// @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 expiration, uint256[] memory tokenIds) { - (contractAddress, chainId, beneficiary, expiration, tokenIds) = - abi.decode(data, (address, uint256, address, uint256, uint256[])); - } - /// @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). tokenIds length capped by MAX_CLAIM_TOKEN_IDS. - /// @param serverId Server id. - /// @param data ABI-encoded (contractAddress, chainId, beneficiary, expiration, tokenIds). - /// @param nonce User nonce (must not be used before). + /// @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( uint8 serverId, bytes calldata data, - uint256 nonce, bytes calldata signature ) external nonReentrant whenNotPaused { - ( - address contractAddress, - uint256 chainId, - address beneficiary, - uint256 expiration, - uint256[] memory tokenIds - ) = decodeClaimData(data); - - if (contractAddress != address(this) || chainId != block.chainid) revert InvalidInput(); - if (tokenIds.length > MAX_CLAIM_TOKEN_IDS) revert InvalidInput(); - - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - if (serverContract.userNonces(beneficiary, nonce)) revert NonceAlreadyUsed(); - - _verifyServerSignature(serverId, beneficiary, expiration, tokenIds, nonce, signature); - serverContract.setUserNonce(beneficiary, nonce, true); - - for (uint256 i = 0; i < tokenIds.length; i++) { - serverContract.claimReward(beneficiary, tokenIds[i]); - emit Claimed(serverId, beneficiary, tokenIds[i]); - } - } - - /// @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( - uint8 serverId, - LibItems.RewardType rewardType, - address to, - address tokenAddress, - uint256[] calldata tokenIds, - uint256[] calldata amounts - ) external onlyServerAdmin(serverId) { - if (to == address(0)) revert AddressIsZero(); - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - - if (rewardType == LibItems.RewardType.ETHER) { - if (amounts.length == 0) revert InvalidInput(); - serverContract.withdrawEtherUnreservedTreasury(to, amounts[0]); - } else if (rewardType == LibItems.RewardType.ERC20) { - serverContract.withdrawUnreservedTreasury(tokenAddress, to); - } else if (rewardType == LibItems.RewardType.ERC721) { - for (uint256 i = 0; i < tokenIds.length; i++) { - serverContract.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++) { - serverContract.withdrawERC1155UnreservedTreasury(tokenAddress, to, tokenIds[i], amounts[i]); - } - } - uint256 emittedAmount = rewardType == LibItems.RewardType.ERC721 - ? tokenIds.length - : (amounts.length > 0 ? amounts[0] : 0); - emit AssetsWithdrawn(serverId, rewardType, to, emittedAmount); + RewardsServer server = getServer(serverId); + server.claim(data, signature); } /*////////////////////////////////////////////////////////////// @@ -586,14 +198,14 @@ contract RewardsRouter is uint256[] memory tokenIds ) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getAllTreasuryBalances(); + 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) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getAllItemIds(); + RewardsServer server = getServer(serverId); + return server.getAllItemIds(); } /// @notice Returns reward definitions for a reward token on the server. @@ -601,8 +213,8 @@ contract RewardsRouter is uint8 serverId, uint256 tokenId ) external view returns (LibItems.Reward[] memory) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getTokenRewards(tokenId); + RewardsServer server = getServer(serverId); + return server.getTokenRewards(tokenId); } /// @notice Returns server treasury ERC20 balance for token. @@ -610,8 +222,8 @@ contract RewardsRouter is uint8 serverId, address token ) external view returns (uint256) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getTreasuryBalance(token); + RewardsServer server = getServer(serverId); + return server.getTreasuryBalance(token); } /// @notice Returns reserved amount for token on the server. @@ -619,8 +231,8 @@ contract RewardsRouter is uint8 serverId, address token ) external view returns (uint256) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getReservedAmount(token); + RewardsServer server = getServer(serverId); + return server.getReservedAmount(token); } /// @notice Returns unreserved (available) treasury balance for token on the server. @@ -628,16 +240,16 @@ contract RewardsRouter is uint8 serverId, address token ) external view returns (uint256) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getAvailableTreasuryBalance(token); + 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) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getWhitelistedTokens(); + RewardsServer server = getServer(serverId); + return server.getWhitelistedTokens(); } /// @notice Returns whether token is whitelisted on the server. @@ -645,26 +257,14 @@ contract RewardsRouter is uint8 serverId, address token ) external view returns (bool) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).isWhitelistedToken(token); + 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) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).isTokenExists(tokenId); - } - - /// @notice Returns whether minting is paused for the reward token on the server. - function isTokenMintPaused(uint8 serverId, uint256 tokenId) external view returns (bool) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).isTokenMintPaused(tokenId); - } - - /// @notice Returns whether claiming is paused for the reward token on the server. - function isClaimRewardPaused(uint8 serverId, uint256 tokenId) external view returns (bool) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).isClaimRewardPaused(tokenId); + RewardsServer server = getServer(serverId); + return server.isTokenExists(tokenId); } /// @notice Returns structured reward token details (URI, maxSupply, reward types/amounts/addresses/tokenIds). @@ -684,8 +284,8 @@ contract RewardsRouter is uint256[] memory rewardTokenId ) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).getTokenDetails(tokenId); + RewardsServer server = getServer(serverId); + return server.getTokenDetails(tokenId); } /// @notice Returns remaining claimable supply for a reward token (maxSupply - currentSupply), or 0 if exhausted/nonexistent. @@ -693,23 +293,29 @@ contract RewardsRouter is uint8 serverId, uint256 tokenId ) external view returns (uint256) { - address server = _getServer(serverId); - RewardsServer serverContract = RewardsServer(payable(server)); - if (!serverContract.isTokenExists(tokenId)) return 0; - uint256 maxSupply = serverContract.getRewardToken(tokenId).maxSupply; - uint256 current = serverContract.currentRewardSupply(tokenId); - if (current >= maxSupply) return 0; - return maxSupply - current; + RewardsServer server = getServer(serverId); + return server.getRemainingRewardSupply(tokenId); } - /// @notice Returns whether the user has already used the given nonce (replay protection). - function isNonceUsed( - uint8 serverId, - address user, - uint256 nonce - ) external view returns (bool) { - address server = _getServer(serverId); - return RewardsServer(payable(server)).userNonces(user, nonce); + /// @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 (treasury) 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 index db1c6dc..d5bb773 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -15,6 +15,15 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I 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); @@ -28,7 +37,9 @@ interface IERC1155Metadata { * 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 { +contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderUpgradeable, ERC1155HolderUpgradeable, ReentrancyGuardUpgradeable, PausableUpgradeable { + + using SafeERC20 for IERC20; /*////////////////////////////////////////////////////////////// ERRORS @@ -42,18 +53,21 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU error InsufficientTreasuryBalance(); error TokenHasReserves(); error InsufficientBalance(); - error UnauthorizedServerAdmin(); error InsufficientERC721Ids(); error ExceedMaxSupply(); error ClaimRewardPaused(); error InvalidInput(); error DupTokenId(); error TransferFailed(); + error InvalidLength(); + error NonceAlreadyUsed(); + error InvalidSignature(); + error SignerAlreadySet(); + error InvalidServerId(); /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ - bytes32 public constant REWARDS_ROUTER_ROLE = keccak256("REWARDS_ROUTER_ROLE"); bytes32 public constant SERVER_ADMIN_ROLE = keccak256("SERVER_ADMIN_ROLE"); /*////////////////////////////////////////////////////////////// @@ -80,28 +94,38 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU uint256[] public itemIds; mapping(uint256 => bool) public tokenExists; mapping(uint256 => LibItems.RewardToken) public tokenRewards; - mapping(uint256 => bool) public isTokenMintPaused; - mapping(uint256 => bool) public isClaimRewardPaused; 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 userNonces; - - // RewardsRouter (has REWARDS_ROUTER_ROLE on this server) - address private rewardsRouter; + mapping(address => mapping(uint256 => bool)) public isUserNonceUsed; // ETH reserved for pending ETHER rewards (this server holds all its treasury ETH) uint256 public ethReservedTotal; - uint256[30] private __gap; + uint8 public id; + + uint256[29] private __gap; /*////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////*/ event TreasuryDeposit(address indexed token, uint256 amount); event SignerUpdated(address indexed account, bool active); - event ServerAdminTransferred(address indexed oldAdmin, address indexed newAdmin); + event AssetsWithdrawn( + LibItems.RewardType rewardType, + address indexed to, + uint256 amount + ); + event RewardSupplyChanged( + uint256 indexed tokenId, + uint256 oldSupply, + uint256 newSupply + ); + event Claimed( + address indexed to, + uint256 indexed tokenId + ); /*////////////////////////////////////////////////////////////// INITIALIZER @@ -112,22 +136,20 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } /// @notice Initializes the server: access roles and server admin. Called once by the proxy. - /// @param _admin Default admin (e.g. RewardsRouter or deployer). - /// @param _rewardsContract Address that receives REWARDS_ROUTER_ROLE (typically RewardsRouter). /// @param _serverAdmin Address that receives SERVER_ADMIN_ROLE (signers, withdrawers, transfer). - function initialize(address _admin, address _rewardsContract, address _serverAdmin) external initializer { - if (_admin == address(0) || _rewardsContract == address(0) || _serverAdmin == address(0)) { + 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, _admin); - _grantRole(REWARDS_ROUTER_ROLE, _rewardsContract); + _grantRole(DEFAULT_ADMIN_ROLE, _serverAdmin); _grantRole(SERVER_ADMIN_ROLE, _serverAdmin); - rewardsRouter = _rewardsContract; + id = _id; } /*////////////////////////////////////////////////////////////// @@ -135,65 +157,35 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU //////////////////////////////////////////////////////////////*/ - /// @notice Returns whether the account is an active signer for claims. - function isSigner(address account) external view returns (bool) { - return signers[account]; - } - - /// @notice Returns list of all active signer addresses (for rewards-get-whitelist-signers). - function getSigners() external view returns (address[] memory) { - return signerList; - } - - /// @notice Returns whether the account is server admin for this RewardsServer. - function isServerAdmin(address account) external view returns (bool) { - return hasRole(SERVER_ADMIN_ROLE, account); - } - /// @notice Same as setSigner but called by RewardsRouter; caller must be SERVER_ADMIN_ROLE. - function setSigner(address account, bool active) external onlyRole(REWARDS_ROUTER_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) { - if (!signers[account]) { - signers[account] = true; - signerList.push(account); - } + signers[account] = true; + signerList.push(account); } else { - if (signers[account]) { - signers[account] = false; - _removeFromSignerList(account); + signers[account] = false; + for (uint256 i = 0; i < signerList.length; i++) { + if (signerList[i] == account) { + signerList[i] = signerList[signerList.length - 1]; + signerList.pop(); + return; + } } } emit SignerUpdated(account, active); } - function _removeFromSignerList(address account) private { - for (uint256 i = 0; i < signerList.length; i++) { - if (signerList[i] == account) { - signerList[i] = signerList[signerList.length - 1]; - signerList.pop(); - return; - } - } - } - - /// @notice Same as transferServerAdmin but initiated by RewardsRouter; caller becomes new admin. - function transferServerAdminAllowedBy(address caller, address newAdmin) external onlyRole(REWARDS_ROUTER_ROLE) { - if (!hasRole(SERVER_ADMIN_ROLE, caller)) revert UnauthorizedServerAdmin(); - if (newAdmin == address(0)) revert AddressIsZero(); - _revokeRole(SERVER_ADMIN_ROLE, caller); - _grantRole(SERVER_ADMIN_ROLE, newAdmin); - emit ServerAdminTransferred(caller, newAdmin); - } - /*////////////////////////////////////////////////////////////// TREASURY MANAGEMENT FUNCTIONS //////////////////////////////////////////////////////////////*/ - /// @notice Adds a token to the whitelist for use in rewards. Only REWARDS_ROUTER_ROLE. + /// @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 onlyRole(REWARDS_ROUTER_ROLE) { + function whitelistToken(address _token, LibItems.RewardType _type) external nonReentrant onlyRole(SERVER_ADMIN_ROLE) { if (_token == address(0)) revert AddressIsZero(); if (whitelistedTokens[_token]) revert TokenAlreadyWhitelisted(); @@ -206,9 +198,9 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } } - /// @notice Removes a token from the whitelist. Fails if the token has any reserves. Only REWARDS_ROUTER_ROLE. + /// @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 onlyRole(REWARDS_ROUTER_ROLE) { + function removeTokenFromWhitelist(address _token) external nonReentrant onlyRole(SERVER_ADMIN_ROLE) { LibItems.RewardType _type = tokenTypes[_token]; if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); @@ -227,11 +219,11 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } } - /// @notice Transfers tokens from _from into this treasury. Only REWARDS_ROUTER_ROLE. Token must be whitelisted. + /// @notice Transfers tokens from _from into this treasury. Token must be whitelisted. /// @param _token Token address (ERC20). /// @param _amount Amount to transfer. /// @param _from Source address (must have approved this contract). - function depositToTreasury(address _token, uint256 _amount, address _from) external onlyRole(REWARDS_ROUTER_ROLE) { + function depositToTreasury(address _token, uint256 _amount, address _from) external nonReentrant { if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); if (_amount == 0) revert InvalidAmount(); @@ -239,8 +231,39 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU emit TreasuryDeposit(_token, _amount); } - /// @notice Sends all unreserved ERC20 balance of _token to _to. Only REWARDS_ROUTER_ROLE. - function withdrawUnreservedTreasury(address _token, address _to) external onlyRole(REWARDS_ROUTER_ROLE) { + /// @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(); @@ -252,8 +275,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU SafeERC20.safeTransfer(IERC20(_token), _to, balance - reserved); } - /// @notice Sends one unreserved ERC721 token to _to. Only REWARDS_ROUTER_ROLE. Fails if _tokenId is reserved. - function withdrawERC721UnreservedTreasury(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_ROUTER_ROLE) { + /// @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(); @@ -261,8 +284,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); } - /// @notice Sends unreserved ERC1155 amount to _to. Only REWARDS_ROUTER_ROLE. - function withdrawERC1155UnreservedTreasury(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_ROUTER_ROLE) { + /// @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(); @@ -275,8 +298,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU IERC1155(_token).safeTransferFrom(address(this), _to, _tokenId, _amount, ""); } - /// @notice Sends unreserved ETH from this server's treasury to _to. Only REWARDS_ROUTER_ROLE. - function withdrawEtherUnreservedTreasury(address _to, uint256 _amount) external onlyRole(REWARDS_ROUTER_ROLE) { + /// @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(); @@ -288,19 +311,188 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU DISTRIBUTION FUNCTIONS (for claims) //////////////////////////////////////////////////////////////*/ - /// @notice Transfers ERC20 to recipient (used when fulfilling claims). Only REWARDS_ROUTER_ROLE. - function distributeERC20(address _token, address _to, uint256 _amount) external onlyRole(REWARDS_ROUTER_ROLE) { - SafeERC20.safeTransfer(IERC20(_token), _to, _amount); + /// @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 Transfers ERC721 to recipient (used when fulfilling claims). Only REWARDS_ROUTER_ROLE. - function distributeERC721(address _token, address _to, uint256 _tokenId) external onlyRole(REWARDS_ROUTER_ROLE) { - IERC721(_token).safeTransferFrom(address(this), _to, _tokenId); + + /// @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 Transfers ERC1155 amount to recipient (used when fulfilling claims). Only REWARDS_ROUTER_ROLE. - function distributeERC1155(address _token, address _to, uint256 _tokenId, uint256 _amount) external onlyRole(REWARDS_ROUTER_ROLE) { - IERC1155(_token).safeTransferFrom(address(this), _to, _tokenId, _amount, ""); + /// @notice Reduces max supply for a reward token; releases proportional ERC20/ERC1155 reservations. Only SERVER_ADMIN_ROLE. + /// @dev New max supply must not be below currentRewardSupply. ERC721: only maxSupply is reduced (reserved NFT ids unchanged). + /// @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 newSupply = rewardToken.maxSupply - _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; + } + // ERC721: no reserved amount to release; maxSupply reduction only + } + + rewardToken.maxSupply = newSupply; + tokenRewards[_tokenId] = rewardToken; + + emit RewardSupplyChanged(_tokenId, current, 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 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(); } /*////////////////////////////////////////////////////////////// @@ -398,6 +590,17 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU 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]; @@ -467,6 +670,14 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } } + /// @notice Decodes claim data for debugging. Same encoding as used in claim(data, 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[])); + } + /// @notice Whether a reward token with _tokenId exists. function isTokenExists(uint256 _tokenId) external view returns (bool) { return tokenExists[_tokenId]; @@ -477,36 +688,58 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU return erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex]; } - /*////////////////////////////////////////////////////////////// - INTERNAL RESERVE / REWARD MUTATORS (for claim & create) - //////////////////////////////////////////////////////////////*/ - function _increaseERC20Reserved(address _token, uint256 _amount) internal { - reservedAmounts[_token] += _amount; + /// @notice Returns list of all active signer addresses (for rewards-get-whitelist-signers). + function getSigners() external view returns (address[] memory) { + return signerList; } - function _decreaseERC20Reserved(address _token, uint256 _amount) internal { - reservedAmounts[_token] -= _amount; - } - function _reserveERC721(address _token, uint256 _tokenId) internal { - isErc721Reserved[_token][_tokenId] = true; - erc721TotalReserved[_token]++; + function supportsInterface(bytes4 interfaceId) public view virtual override(AccessControlUpgradeable, ERC1155HolderUpgradeable) returns (bool) { + return super.supportsInterface(interfaceId); } - function _releaseERC721(address _token, uint256 _tokenId) internal { - isErc721Reserved[_token][_tokenId] = false; - erc721TotalReserved[_token]--; - } + /*////////////////////////////////////////////////////////////// + INTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ - function _increaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) internal { - erc1155ReservedAmounts[_token][_tokenId] += _amount; - erc1155TotalReserved[_token] += _amount; + /** + * @dev Internal helper to verify a server-scoped signature. + * + * Message format (hashed then wrapped in EIP-191 prefix): + * keccak256(abi.encodePacked(contractAddress, chainId, serverId, beneficiary, userNonce, tokenIds)) + */ + 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; } - function _decreaseERC1155Reserved(address _token, uint256 _tokenId, uint256 _amount) internal { - erc1155ReservedAmounts[_token][_tokenId] -= _amount; - erc1155TotalReserved[_token] -= _amount; - } + /*////////////////////////////////////////////////////////////// + INTERNAL HELPER FUNCTIONS + //////////////////////////////////////////////////////////////*/ function _addRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) internal { if (tokenExists[_tokenId]) revert RewardTokenAlreadyExists(); @@ -516,192 +749,18 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU currentRewardSupply[_tokenId] = 0; } - function _updateRewardToken(uint256 _tokenId, LibItems.RewardToken memory _rewardToken) internal { - if (!tokenExists[_tokenId]) revert TokenNotExist(); - tokenRewards[_tokenId] = _rewardToken; - } - - function _increaseCurrentSupply(uint256 _tokenId, uint256 _amount) internal { - currentRewardSupply[_tokenId] += _amount; - } - - function _incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex, uint256 _delta) internal { - erc721RewardCurrentIndex[_rewardTokenId][_rewardIndex] += _delta; - } - - - /// @notice Increases max supply for a reward token; reserves additional ERC20/ERC1155/ETH on this server. Only REWARDS_ROUTER_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 onlyRole(REWARDS_ROUTER_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; - } - - /// @notice Reduces max supply for a reward token; releases proportional ERC20/ERC1155 reservations. Only REWARDS_ROUTER_ROLE. - /// @dev New max supply must not be below currentRewardSupply. ERC721: only maxSupply is reduced (reserved NFT ids unchanged). - /// @param _tokenId Reward token id. - /// @param _reduceBy Amount to subtract from max supply (must be > 0). - function reduceRewardSupply(uint256 _tokenId, uint256 _reduceBy) external onlyRole(REWARDS_ROUTER_ROLE) { - if (!tokenExists[_tokenId]) revert TokenNotExist(); - if (_reduceBy == 0) revert InvalidAmount(); - - LibItems.RewardToken memory rewardToken = tokenRewards[_tokenId]; - uint256 current = currentRewardSupply[_tokenId]; - uint256 newSupply = rewardToken.maxSupply - _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; - } - // ERC721: no reserved amount to release; maxSupply reduction only - } - - rewardToken.maxSupply = newSupply; - tokenRewards[_tokenId] = rewardToken; - } - - /// @notice Pauses or unpauses minting for a reward token. Only REWARDS_ROUTER_ROLE. - function setTokenMintPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_ROUTER_ROLE) { - isTokenMintPaused[_tokenId] = _isPaused; - } - - /// @notice Pauses or unpauses claiming rewards for a reward token. Only REWARDS_ROUTER_ROLE. - function setClaimRewardPaused(uint256 _tokenId, bool _isPaused) external onlyRole(REWARDS_ROUTER_ROLE) { - isClaimRewardPaused[_tokenId] = _isPaused; - } - - /// @notice Sets a user nonce as used/unused (replay protection for mint/claim). Only REWARDS_ROUTER_ROLE. - function setUserNonce(address _user, uint256 _nonce, bool _used) external onlyRole(REWARDS_ROUTER_ROLE) { - userNonces[_user][_nonce] = _used; - } - - /// @notice Advances the ERC721 distribution index for a reward slot by _delta (e.g. after distributing rewardAmount NFTs). Only REWARDS_ROUTER_ROLE. - function incrementERC721RewardIndex(uint256 _rewardTokenId, uint256 _rewardIndex, uint256 _delta) external onlyRole(REWARDS_ROUTER_ROLE) { - _incrementERC721RewardIndex(_rewardTokenId, _rewardIndex, _delta); - } - - /*////////////////////////////////////////////////////////////// - VALIDATE & CREATE / UPDATE URI / CLAIM (REWARDS_ROUTER_ROLE) - //////////////////////////////////////////////////////////////*/ - - /// @notice Validates and creates a new reward token; reserves ERC20/721/1155 and ETH on this server. Only REWARDS_ROUTER_ROLE. Send ETH if token has ETHER rewards. - function createTokenAndReserveRewards(LibItems.RewardToken calldata token) external payable onlyRole(REWARDS_ROUTER_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(); - _increaseERC20Reserved(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++) { - _reserveERC721(r.rewardTokenAddress, r.rewardTokenIds[j]); - } - } 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(); - _increaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, totalAmount); - } - } - - LibItems.RewardToken memory tokenMem = token; - _addRewardToken(token.tokenId, tokenMem); - } - /// @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) external onlyRole(REWARDS_ROUTER_ROLE) { + function _claimReward(address to, uint256 tokenId) internal { if (to == address(0)) revert AddressIsZero(); if (!tokenExists[tokenId]) revert TokenNotExist(); - if (isClaimRewardPaused[tokenId]) revert ClaimRewardPaused(); 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). @@ -718,19 +777,21 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU if (!ok) revert TransferFailed(); } else if (r.rewardType == LibItems.RewardType.ERC20) { SafeERC20.safeTransfer(IERC20(r.rewardTokenAddress), to, r.rewardAmount); - _decreaseERC20Reserved(r.rewardTokenAddress, 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]; - _releaseERC721(r.rewardTokenAddress, nftId); + isErc721Reserved[r.rewardTokenAddress][nftId] = false; + erc721TotalReserved[r.rewardTokenAddress]--; IERC721(r.rewardTokenAddress).safeTransferFrom(address(this), to, nftId); } - _incrementERC721RewardIndex(rewardTokenId, i, r.rewardAmount); + erc721RewardCurrentIndex[rewardTokenId][i] += r.rewardAmount; } else if (r.rewardType == LibItems.RewardType.ERC1155) { - _decreaseERC1155Reserved(r.rewardTokenAddress, r.rewardTokenId, r.rewardAmount); + erc1155ReservedAmounts[r.rewardTokenAddress][r.rewardTokenId] -= r.rewardAmount; + erc1155TotalReserved[r.rewardTokenAddress] -= r.rewardAmount; IERC1155(r.rewardTokenAddress).safeTransferFrom(address(this), to, r.rewardTokenId, r.rewardAmount, ""); } } @@ -925,9 +986,6 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU return count; } - function supportsInterface(bytes4 interfaceId) public view virtual override(AccessControlUpgradeable, ERC1155HolderUpgradeable) returns (bool) { - return super.supportsInterface(interfaceId); - } /// @notice Accepts ETH sent to this server's treasury (e.g. for topping up unreserved ETH). receive() external payable {} diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts index a06ac62..ba6bf7b 100644 --- a/test/rewardsManager.test.ts +++ b/test/rewardsManager.test.ts @@ -2,6 +2,13 @@ import { expect } from 'chai'; import { ethers, upgrades } from 'hardhat'; import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +/** + * RewardsRouter + RewardsServer tests. + * 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) depositToTreasury is permissionless; 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; @@ -34,29 +41,66 @@ describe('RewardsRouter', function () { 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')); - // devWallet is initial server admin; use manager helper to grant SERVER_ADMIN_ROLE to managerWallet - await base.manager.connect(base.devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, base.managerWallet.address); - await base.manager.connect(base.managerWallet).addWhitelistSigner(SERVER_ID, base.managerWallet.address); + 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 base.manager - .connect(base.managerWallet) - .whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1); // ERC20 - await mockERC20 - .connect(base.managerWallet) - .approve(await base.manager.getServer(SERVER_ID), ethers.parseEther('1000')); - await base.manager - .connect(base.managerWallet) - .depositToTreasury(SERVER_ID, await mockERC20.getAddress(), ethers.parseEther('1000')); - - return { ...base, mockERC20 }; + await server.connect(base.managerWallet).whitelistToken(await mockERC20.getAddress(), 1); // ERC20 + await mockERC20.connect(base.managerWallet).approve(serverAddr, ethers.parseEther('1000')); + await server.connect(base.managerWallet).depositToTreasury(await mockERC20.getAddress(), ethers.parseEther('1000'), base.managerWallet.address); + + return { ...base, server, mockERC20 }; } + describe('initializeBeacons', function () { + it('DEV_CONFIG_ROLE can call initializeBeacons with non-zero implementation', async function () { + const [devWallet, managerWallet] = await ethers.getSigners(); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const router = await upgrades.deployProxy( + RewardsRouter, + [devWallet.address, managerWallet.address], + { kind: 'uups', initializer: 'initialize' } + ); + await router.waitForDeployment(); + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const impl = await RewardsServerImpl.deploy(); + await impl.waitForDeployment(); + const implAddress = await impl.getAddress(); + expect(await router.serverBeacon()).to.equal(ethers.ZeroAddress); + await router.connect(devWallet).initializeBeacons(implAddress); + expect(await router.serverBeacon()).to.not.equal(ethers.ZeroAddress); + }); + + it('reverts with AddressIsZero when implementation is zero', async function () { + const [devWallet, managerWallet] = await ethers.getSigners(); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const router = await upgrades.deployProxy( + RewardsRouter, + [devWallet.address, managerWallet.address], + { kind: 'uups', initializer: 'initialize' } + ); + await router.waitForDeployment(); + await expect(router.connect(devWallet).initializeBeacons(ethers.ZeroAddress)) + .to.be.revertedWithCustomError(router, 'AddressIsZero'); + }); + + it('reverts with BeaconsAlreadyInitialized when called twice', async function () { + const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); + const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); + const impl2 = await RewardsServerImpl.deploy(); + await impl2.waitForDeployment(); + await expect(manager.connect(devWallet).initializeBeacons(await impl2.getAddress())) + .to.be.revertedWithCustomError(manager, 'BeaconsAlreadyInitialized'); + }); + }); + describe('deployServer', function () { it('should deploy a server with RewardsServer', async function () { const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); @@ -78,6 +122,19 @@ describe('RewardsRouter', function () { await expect(manager.connect(managerWallet).deployServer(0, devWallet.address)) .to.be.revertedWithCustomError(manager, 'InvalidServerId'); }); + + it('reverts with BeaconNotInitialized when beacons not set', async function () { + const [devWallet, managerWallet] = await ethers.getSigners(); + const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); + const router = await upgrades.deployProxy( + RewardsRouter, + [devWallet.address, managerWallet.address], + { kind: 'uups', initializer: 'initialize' } + ); + await router.waitForDeployment(); + await expect(router.connect(managerWallet).deployServer(SERVER_ID, devWallet.address)) + .to.be.revertedWithCustomError(router, 'BeaconNotInitialized'); + }); }); // RewardsFactory ownership tests removed: deployment is now handled directly by RewardsManager.deployServer. @@ -86,47 +143,45 @@ describe('RewardsRouter', 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')); - // devWallet (initial server admin) grants SERVER_ADMIN_ROLE to user1 via manager helper - await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); - await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); - expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.true; + 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 SERVER_ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('SERVER_ADMIN_ROLE')); - await manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); const serverAddr = await manager.getServer(SERVER_ID); const server = await ethers.getContractAt('RewardsServer', serverAddr); - await expect( - manager.connect(user2).addWhitelistSigner(SERVER_ID, user2.address) - ).to.be.revertedWithCustomError(server, 'UnauthorizedServerAdmin'); + 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'); }); }); - /** Build claim data and signature for claim(). Signer must be set as server signer. */ + /** Build claim data and signature for claim(). Targets the given server; signer must be a server signer. */ async function buildClaimDataAndSignature( - manager: Awaited>, + serverAddress: string, serverId: number, signer: Awaited>[0], beneficiary: string, tokenIds: number[], - expiration: number, - nonce: number + userNonce: number ) { const chainId = (await ethers.provider.getNetwork()).chainId; - const managerAddress = await manager.getAddress(); const data = ethers.AbiCoder.defaultAbiCoder().encode( - ['address', 'uint256', 'address', 'uint256', 'uint256[]'], - [managerAddress, chainId, beneficiary, expiration, tokenIds] + ['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[]', 'uint256'], - [managerAddress, chainId, serverId, beneficiary, expiration, tokenIds, nonce] + ['address', 'uint256', 'uint8', 'address', 'uint256', 'uint256[]'], + [serverAddress, chainId, serverId, beneficiary, BigInt(userNonce), tokenIds] ) ); const signature = await signer.signMessage(ethers.getBytes(messageHash)); @@ -144,7 +199,7 @@ describe('RewardsRouter', function () { }); it('should create reward token and claim with signature', async function () { - const { manager, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 1; const rewardToken = { tokenId, @@ -161,31 +216,27 @@ describe('RewardsRouter', function () { ], }; - await manager - .connect(managerWallet) - .createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); expect(await manager.isTokenExist(SERVER_ID, tokenId)).to.be.true; - const expiration = Math.floor(Date.now() / 1000) + 3600; const { data, signature } = await buildClaimDataAndSignature( - manager, + await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 0 ); const before = await mockERC20.balanceOf(user1.address); - await manager.connect(user1).claim(SERVER_ID, data, 0, signature); + await manager.connect(user1).claim(SERVER_ID, 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, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + const { manager, server, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 2; const rewardAmount = ethers.parseEther('0.5'); const rewardToken = { @@ -203,29 +254,25 @@ describe('RewardsRouter', function () { ], }; const ethRequired = rewardAmount * 2n; // maxSupply 2 - await manager - .connect(managerWallet) - .createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: ethRequired }); - const expiration = Math.floor(Date.now() / 1000) + 3600; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: ethRequired }); const { data, signature } = await buildClaimDataAndSignature( - manager, + await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 0 ); const before = await ethers.provider.getBalance(user1.address); - const tx = await manager.connect(user1).claim(SERVER_ID, data, 0, signature); + const tx = await manager.connect(user1).claim(SERVER_ID, 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('MANAGER_ROLE can withdraw unreserved ETHER from server treasury', async function () { - const { manager, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + 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, @@ -241,44 +288,37 @@ describe('RewardsRouter', function () { }, ], }; - await manager - .connect(managerWallet) - .createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: ethers.parseEther('1') }); - const expiration = Math.floor(Date.now() / 1000) + 3600; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: ethers.parseEther('1') }); const { data, signature } = await buildClaimDataAndSignature( - manager, + await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 0 ); - await manager.connect(user1).claim(SERVER_ID, data, 0, signature); + await manager.connect(user1).claim(SERVER_ID, data, signature); const extraEth = ethers.parseEther('0.3'); - const serverTreasury = await manager.getServer(SERVER_ID); + const serverAddr = await manager.getServer(SERVER_ID); await managerWallet.sendTransaction({ - to: serverTreasury, + to: serverAddr, value: extraEth, }); const before = await ethers.provider.getBalance(user1.address); - await manager - .connect(managerWallet) - .withdrawAssets( - SERVER_ID, - 0, // ETHER - user1.address, - ethers.ZeroAddress, - [], - [extraEth] - ); + 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, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 1; const rewardToken = { tokenId, @@ -294,35 +334,31 @@ describe('RewardsRouter', function () { }, ], }; - await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); - const expiration = Math.floor(Date.now() / 1000) + 3600; - + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); const { data: data1, signature: sig1 } = await buildClaimDataAndSignature( - manager, + await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 0 ); const { data: data2, signature: sig2 } = await buildClaimDataAndSignature( - manager, + await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 1 ); - await manager.connect(user1).claim(SERVER_ID, data1, 0, sig1); - await manager.connect(user1).claim(SERVER_ID, data2, 1, sig2); + await manager.connect(user1).claim(SERVER_ID, data1, sig1); + await manager.connect(user1).claim(SERVER_ID, 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, managerWallet, user1, user2, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const { manager, server, managerWallet, user1, user2, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 1; const rewardToken = { tokenId, @@ -338,31 +374,29 @@ describe('RewardsRouter', function () { }, ], }; - await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); - const expiration = Math.floor(Date.now() / 1000) + 3600; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); const { data, signature } = await buildClaimDataAndSignature( - manager, + await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 0 ); const before = await mockERC20.balanceOf(user1.address); - await manager.connect(user2).claim(SERVER_ID, data, 0, signature); + await manager.connect(user2).claim(SERVER_ID, 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, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + 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 manager.getServer(SERVER_ID); - await manager.connect(managerWallet).whitelistToken(SERVER_ID, await mockERC721.getAddress(), 2); // ERC721 + 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); } @@ -385,41 +419,38 @@ describe('RewardsRouter', function () { }, ], }; - await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); - const expiration = Math.floor(Date.now() / 1000) + 3600; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); const { data: data0, signature: sig0 } = await buildClaimDataAndSignature( - manager, + serverAddr, SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 0 ); - await manager.connect(user1).claim(SERVER_ID, data0, 0, sig0); + await manager.connect(user1).claim(SERVER_ID, data0, sig0); expect(await mockERC721.ownerOf(0)).to.equal(user1.address); const { data: data1, signature: sig1 } = await buildClaimDataAndSignature( - manager, + serverAddr, SERVER_ID, managerWallet, user1.address, [tokenId], - expiration, 1 ); - await manager.connect(user1).claim(SERVER_ID, data1, 1, sig1); + await manager.connect(user1).claim(SERVER_ID, 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, managerWallet, user1 } = await loadFixture(deployWithServerAndTokenFixture); + 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 manager.getServer(SERVER_ID); - await manager.connect(managerWallet).whitelistToken(SERVER_ID, await mockERC1155.getAddress(), 3); // ERC1155 + 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'); @@ -442,47 +473,62 @@ describe('RewardsRouter', function () { }, ], }; - await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); - const expiration = Math.floor(Date.now() / 1000) + 3600; + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); const { data, signature } = await buildClaimDataAndSignature( - manager, + serverAddr, SERVER_ID, managerWallet, user1.address, [rewardTokenId], - expiration, 0 ); - await manager.connect(user1).claim(SERVER_ID, data, 0, signature); + await manager.connect(user1).claim(SERVER_ID, data, signature); expect(await mockERC1155.balanceOf(user1.address, erc1155TokenId)).to.equal(10); }); }); }); + describe('security assumptions', function () { + it('depositToTreasury is permissionless; only SERVER_ADMIN can withdraw', 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).approve(await server.getAddress(), extra); + await server.connect(user2).depositToTreasury(await mockERC20.getAddress(), extra, user2.address); + 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 manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); + 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( - manager.connect(user2).whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1) - ).to.be.revertedWithCustomError(manager, 'UnauthorizedServerAdmin'); + server.connect(user2).whitelistToken(await mockERC20.getAddress(), 1) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); }); - it('non-server-admin cannot call createTokenAndDepositRewards', async function () { + 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 base.manager.connect(base.devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, base.user1.address); + 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(); - // whitelist by server admin - await base.manager.connect(base.user1).whitelistToken(SERVER_ID, await mockERC20.getAddress(), 1); + await server.connect(base.user1).whitelistToken(await mockERC20.getAddress(), 1); const rewardToken = { tokenId: 1, tokenUri: 'u', @@ -498,67 +544,78 @@ describe('RewardsRouter', function () { ], }; await expect( - base.manager.connect(base.user2).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }) - ).to.be.revertedWithCustomError(base.manager, 'UnauthorizedServerAdmin'); + 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 base.manager.connect(base.devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, base.user1.address); + await server.connect(base.devWallet).grantRole(SERVER_ADMIN_ROLE, base.user1.address); await expect( - base.manager - .connect(base.user2) - .withdrawAssets(SERVER_ID, 1, base.user2.address, ethers.ZeroAddress, [], []) - ).to.be.revertedWithCustomError(base.manager, 'UnauthorizedServerAdmin'); + server.connect(base.user2).withdrawAssets(1, base.user2.address, ethers.ZeroAddress, [], []) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); }); - it('non-MANAGER cannot call pause', async 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 FACTORY_ROLE can call registerServer', async function () { + it('only MANAGER_ROLE can call deployServer', async function () { const { manager, user1 } = await loadFixture(deployRewardsManagerFixture); - const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); - const impl = await RewardsServerImpl.deploy(); - await impl.waitForDeployment(); - await expect( - manager.connect(user1).registerServer(2, await impl.getAddress()) - ).to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); + await expect(manager.connect(user1).deployServer(2, user1.address)) + .to.be.revertedWithCustomError(manager, 'AccessControlUnauthorizedAccount'); }); }); - describe('admin proxy and wrapper functions', function () { - describe('pause views (isTokenMintPaused, isClaimRewardPaused)', function () { - it('isTokenMintPaused and isClaimRewardPaused reflect server state', async function () { - const { manager, 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 manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); - expect(await manager.isTokenMintPaused(SERVER_ID, tokenId)).to.be.false; - expect(await manager.isClaimRewardPaused(SERVER_ID, tokenId)).to.be.false; - await manager.connect(managerWallet).updateTokenMintPaused(SERVER_ID, tokenId, true); - expect(await manager.isTokenMintPaused(SERVER_ID, tokenId)).to.be.true; - await manager.connect(managerWallet).updateClaimRewardPaused(SERVER_ID, tokenId, true); - expect(await manager.isClaimRewardPaused(SERVER_ID, tokenId)).to.be.true; - }); + describe('router view proxies and claim', function () { + it('router getServerTreasuryBalances and getTokenDetails mirror server state', 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 }); + 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 { 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(managerWallet).pause(); + await expect(manager.connect(user1).claim(SERVER_ID, data, signature)) + .to.be.revertedWithCustomError(manager, 'EnforcedPause'); + await manager.connect(managerWallet).unpause(); + await manager.connect(user1).claim(SERVER_ID, data, signature); + expect(await mockERC20.balanceOf(user1.address)).to.equal(ethers.parseEther('10')); + }); + }); + + 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); @@ -567,23 +624,25 @@ describe('RewardsRouter', function () { expect(list.length).to.equal(0); }); - it('returns signers after addWhitelistSigner', async function () { + 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 manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); + await server.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, user1.address); let list = await manager.getServerSigners(SERVER_ID); expect(list.length).to.equal(0); - await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); + 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 manager.connect(user1).addWhitelistSigner(SERVER_ID, user1.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 manager.connect(user1).removeWhitelistSigner(SERVER_ID, user2.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); @@ -594,9 +653,11 @@ describe('RewardsRouter', 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 manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, managerWallet.address); - await manager.connect(managerWallet).addWhitelistSigner(SERVER_ID, managerWallet.address); + await serverContract.connect(devWallet).grantRole(SERVER_ADMIN_ROLE, managerWallet.address); + await serverContract.connect(managerWallet).setSigner(managerWallet.address, true); const tokenId = 1; const rewardToken = { tokenId, @@ -604,11 +665,10 @@ describe('RewardsRouter', function () { maxSupply: 2, rewards: [{ rewardType: 0, rewardAmount: ethers.parseEther('1'), rewardTokenAddress: ethers.ZeroAddress, rewardTokenIds: [], rewardTokenId: 0 }], }; - await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: ethers.parseEther('2') }); - const expiration = Math.floor(Date.now() / 1000) + 3600; - const { data, signature } = await buildClaimDataAndSignature(manager, SERVER_ID, managerWallet, user1.address, [tokenId], expiration, 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(SERVER_ID, data, 0, signature); + const tx = await manager.connect(user1).claim(SERVER_ID, data, signature); const receipt = await tx.wait(); const gasCost = receipt!.gasUsed * receipt!.gasPrice; const afterBalance = await ethers.provider.getBalance(user1.address); @@ -616,23 +676,150 @@ describe('RewardsRouter', function () { }); }); - describe('addWhitelistSigner and removeWhitelistSigner', function () { - it('addWhitelistSigner enables signer, removeWhitelistSigner disables', async 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 manager.connect(devWallet).grantServerRole(SERVER_ID, SERVER_ADMIN_ROLE, user1.address); - expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.false; - await manager.connect(user1).addWhitelistSigner(SERVER_ID, user2.address); - expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.true; - await manager.connect(user1).removeWhitelistSigner(SERVER_ID, user2.address); - expect(await manager.isServerSigner(SERVER_ID, user2.address)).to.be.false; + 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).approve(await server.getAddress(), ethers.parseEther('100')); + await server.connect(managerWallet).depositToTreasury(await mockERC20.getAddress(), ethers.parseEther('50'), managerWallet.address); + 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'); + }); + }); + + 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(SERVER_ID, data, signature); + await expect(manager.connect(user1).claim(SERVER_ID, 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(SERVER_ID, 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 { data, 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); + await expect(manager.connect(user1).claim(2, data, signature)) + .to.be.revertedWithCustomError(server2, 'InvalidInput'); + }); + + 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(SERVER_ID, data, signature)) + .to.be.revertedWithCustomError(server, 'InvalidInput'); }); }); describe('reduceRewardSupply', function () { - it('MANAGER_ROLE can reduce reward supply and event is emitted', async function () { - const { manager, managerWallet, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + 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, @@ -648,19 +835,19 @@ describe('RewardsRouter', function () { }, ], }; - await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 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(manager.connect(managerWallet).reduceRewardSupply(SERVER_ID, tokenId, 3)) - .to.emit(manager, 'RewardSupplyChanged') - .withArgs(SERVER_ID, tokenId, 10, 7); + await expect(server.connect(managerWallet).reduceRewardSupply(tokenId, 3)) + .to.emit(server, 'RewardSupplyChanged') + .withArgs(tokenId, 0, 7); // currentSupply was 0, 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, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); + const { manager, server, managerWallet, user1, mockERC20 } = await loadFixture(deployWithServerAndTokenFixture); const tokenId = 1; const rewardToken = { tokenId, @@ -676,11 +863,10 @@ describe('RewardsRouter', function () { }, ], }; - // Server admin (managerWallet) creates the reward - await manager.connect(managerWallet).createTokenAndDepositRewards(SERVER_ID, rewardToken, { value: 0 }); + await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); await expect( - manager.connect(user1).reduceRewardSupply(SERVER_ID, tokenId, 2) - ).to.be.revertedWithCustomError(manager, 'UnauthorizedServerAdmin'); + server.connect(user1).reduceRewardSupply(tokenId, 2) + ).to.be.revertedWithCustomError(server, 'AccessControlUnauthorizedAccount'); }); }); }); From fe8b4e06450c7e16c63bc70229b6c872cbbeeb46 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Wed, 25 Feb 2026 15:20:34 -0300 Subject: [PATCH 7/9] Chore: Update tests --- .../upgradeables/soulbounds/RewardsRouter.sol | 1 + .../upgradeables/soulbounds/RewardsServer.sol | 45 ++++++++++--------- test/rewardsManager.test.ts | 15 +++---- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/contracts/upgradeables/soulbounds/RewardsRouter.sol b/contracts/upgradeables/soulbounds/RewardsRouter.sol index 5822bcc..4eb4cea 100644 --- a/contracts/upgradeables/soulbounds/RewardsRouter.sol +++ b/contracts/upgradeables/soulbounds/RewardsRouter.sol @@ -85,6 +85,7 @@ contract RewardsRouter is /// @notice Initializes roles (dev, router, upgrader). Called once by the proxy. /// @param _devWallet Receives DEFAULT_ADMIN_ROLE, DEV_CONFIG_ROLE, UPGRADER_ROLE. /// @param _routerWallet Receives MANAGER_ROLE. + /// @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 _routerWallet diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol index d5bb773..6a4902b 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -105,7 +105,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU uint8 public id; - uint256[29] private __gap; + uint256[28] private __gap; /*////////////////////////////////////////////////////////////// EVENTS @@ -117,6 +117,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU 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, @@ -137,6 +139,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU /// @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(); @@ -171,6 +174,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU if (signerList[i] == account) { signerList[i] = signerList[signerList.length - 1]; signerList.pop(); + emit SignerUpdated(account, false); return; } } @@ -219,18 +223,6 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } } - /// @notice Transfers tokens from _from into this treasury. Token must be whitelisted. - /// @param _token Token address (ERC20). - /// @param _amount Amount to transfer. - /// @param _from Source address (must have approved this contract). - function depositToTreasury(address _token, uint256 _amount, address _from) external nonReentrant { - if (!whitelistedTokens[_token]) revert TokenNotWhitelisted(); - if (_amount == 0) revert InvalidAmount(); - - SafeERC20.safeTransferFrom(IERC20(_token), _from, address(this), _amount); - emit TreasuryDeposit(_token, _amount); - } - /// @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, @@ -404,8 +396,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU _addRewardToken(token.tokenId, tokenMem); } - /// @notice Reduces max supply for a reward token; releases proportional ERC20/ERC1155 reservations. Only SERVER_ADMIN_ROLE. - /// @dev New max supply must not be below currentRewardSupply. ERC721: only maxSupply is reduced (reserved NFT ids unchanged). + /// @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) { @@ -414,7 +406,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU LibItems.RewardToken memory rewardToken = tokenRewards[_tokenId]; uint256 current = currentRewardSupply[_tokenId]; - uint256 newSupply = rewardToken.maxSupply - _reduceBy; + uint256 oldMaxSupply = rewardToken.maxSupply; + uint256 newSupply = oldMaxSupply - _reduceBy; if (current > newSupply) revert InsufficientBalance(); for (uint256 i = 0; i < rewardToken.rewards.length; i++) { @@ -428,21 +421,27 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU 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]--; + } } - // ERC721: no reserved amount to release; maxSupply reduction only } rewardToken.maxSupply = newSupply; tokenRewards[_tokenId] = rewardToken; - emit RewardSupplyChanged(_tokenId, current, newSupply); + 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 onlyRole(SERVER_ADMIN_ROLE) { + function increaseRewardSupply(uint256 _tokenId, uint256 _additionalSupply) external payable nonReentrant onlyRole(SERVER_ADMIN_ROLE) { if (!tokenExists[_tokenId]) revert TokenNotExist(); if (_additionalSupply == 0) revert InvalidAmount(); @@ -671,6 +670,8 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU } /// @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) { @@ -705,8 +706,10 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU /** * @dev Internal helper to verify a server-scoped signature. * - * Message format (hashed then wrapped in EIP-191 prefix): - * keccak256(abi.encodePacked(contractAddress, chainId, serverId, beneficiary, userNonce, tokenIds)) + * 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, diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts index ba6bf7b..b20e106 100644 --- a/test/rewardsManager.test.ts +++ b/test/rewardsManager.test.ts @@ -6,7 +6,7 @@ import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; * RewardsRouter + RewardsServer tests. * 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) depositToTreasury is permissionless; only SERVER_ADMIN can withdraw. (4) Signature for one server cannot + * (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 () { @@ -53,8 +53,7 @@ describe('RewardsRouter', function () { 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).approve(serverAddr, ethers.parseEther('1000')); - await server.connect(base.managerWallet).depositToTreasury(await mockERC20.getAddress(), ethers.parseEther('1000'), base.managerWallet.address); + await mockERC20.connect(base.managerWallet).transfer(serverAddr, ethers.parseEther('1000')); return { ...base, server, mockERC20 }; } @@ -489,12 +488,11 @@ describe('RewardsRouter', function () { }); describe('security assumptions', function () { - it('depositToTreasury is permissionless; only SERVER_ADMIN can withdraw', async 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).approve(await server.getAddress(), extra); - await server.connect(user2).depositToTreasury(await mockERC20.getAddress(), extra, user2.address); + 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(), [], []) @@ -707,8 +705,7 @@ describe('RewardsRouter', function () { }; await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); expect(await manager.getRemainingSupply(SERVER_ID, tokenId)).to.equal(5); - await mockERC20.connect(managerWallet).approve(await server.getAddress(), ethers.parseEther('100')); - await server.connect(managerWallet).depositToTreasury(await mockERC20.getAddress(), ethers.parseEther('50'), managerWallet.address); + 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); @@ -840,7 +837,7 @@ describe('RewardsRouter', function () { expect(details.maxSupply).to.equal(10); await expect(server.connect(managerWallet).reduceRewardSupply(tokenId, 3)) .to.emit(server, 'RewardSupplyChanged') - .withArgs(tokenId, 0, 7); // currentSupply was 0, newSupply 7 + .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); From 64eda274e7c057cebc7c194ab4045885a42d1769 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Thu, 26 Feb 2026 10:39:09 -0300 Subject: [PATCH 8/9] Fix: Add events for webhook --- .../upgradeables/soulbounds/RewardsRouter.sol | 47 +++--- .../upgradeables/soulbounds/RewardsServer.sol | 2 + scripts/deployRewardsManager.ts | 31 ++-- test/rewardsManager.test.ts | 135 ++++++++---------- 4 files changed, 98 insertions(+), 117 deletions(-) diff --git a/contracts/upgradeables/soulbounds/RewardsRouter.sol b/contracts/upgradeables/soulbounds/RewardsRouter.sol index 4eb4cea..7432adb 100644 --- a/contracts/upgradeables/soulbounds/RewardsRouter.sol +++ b/contracts/upgradeables/soulbounds/RewardsRouter.sol @@ -44,8 +44,8 @@ contract RewardsRouter is error ServerDoesNotExist(); error InvalidServerId(); error BeaconNotInitialized(); - error BeaconsAlreadyInitialized(); - + event ServerBeaconSet(address indexed serverBeacon); + /*////////////////////////////////////////////////////////////// CONSTANTS //////////////////////////////////////////////////////////////*/ @@ -71,7 +71,8 @@ contract RewardsRouter is EVENTS //////////////////////////////////////////////////////////////*/ - event ServerDeployed(uint8 indexed serverId, address treasury); + event ServerDeployed(uint8 indexed serverId, address indexed server); + event RewardClaimed(uint8 indexed serverId, address indexed user, uint256 indexed nonce, uint256[] tokenIds); /*////////////////////////////////////////////////////////////// INITIALIZER @@ -84,16 +85,13 @@ contract RewardsRouter is /// @notice Initializes roles (dev, router, upgrader). Called once by the proxy. /// @param _devWallet Receives DEFAULT_ADMIN_ROLE, DEV_CONFIG_ROLE, UPGRADER_ROLE. - /// @param _routerWallet Receives MANAGER_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 _routerWallet + address _serverImplementation ) external initializer { - if ( - _devWallet == address(0) || - _routerWallet == address(0) - ) { + if (_devWallet == address(0) || _serverImplementation == address(0)) { revert AddressIsZero(); } @@ -104,9 +102,13 @@ contract RewardsRouter is _grantRole(DEFAULT_ADMIN_ROLE, _devWallet); _grantRole(DEV_CONFIG_ROLE, _devWallet); _grantRole(UPGRADER_ROLE, _devWallet); - _grantRole(MANAGER_ROLE, _routerWallet); + _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) {} @@ -129,23 +131,19 @@ contract RewardsRouter is BEACON CONFIGURATION //////////////////////////////////////////////////////////////*/ - /// @notice Sets the RewardsServer implementation beacon. Callable once by DEV_CONFIG_ROLE. - /// @param _serverImplementation Implementation contract for RewardsServer (BeaconProxy targets this). - function initializeBeacons(address _serverImplementation) external onlyRole(DEV_CONFIG_ROLE) { - if (address(_serverImplementation) == address(0)) { + /// @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(); } - if (serverBeacon != address(0)) { - revert BeaconsAlreadyInitialized(); - } - serverBeacon = address(new UpgradeableBeacon( - _serverImplementation, - address(this) - )); + serverBeacon = _serverBeacon; + + emit ServerBeaconSet(serverBeacon); } - /// @notice Deploys and registers a new RewardsServer treasury for the given serverId. Only MANAGER_ROLE. + /// @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). function deployServer(uint8 serverId, address serverAdmin) external nonReentrant onlyRole(MANAGER_ROLE) returns (address server) { @@ -170,12 +168,13 @@ contract RewardsRouter is /// @param data ABI-encoded (contractAddress, chainId, beneficiary, userNonce, serverId, tokenIds). /// @param signature Server signer signature over the claim message. function claim( - uint8 serverId, 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); } /*////////////////////////////////////////////////////////////// @@ -304,7 +303,7 @@ contract RewardsRouter is return server.getSigners(); } - /// @notice Returns the RewardsServer (treasury) address for a server. + /// @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(); diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol index 6a4902b..d8fb173 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -128,6 +128,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU address indexed to, uint256 indexed tokenId ); + event UserNonceUsed(address indexed user, uint256 indexed nonce); /*////////////////////////////////////////////////////////////// INITIALIZER @@ -738,6 +739,7 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU if (serverId != id) revert InvalidServerId(); if (isUserNonceUsed[beneficiary][userNonce]) revert NonceAlreadyUsed(); isUserNonceUsed[beneficiary][userNonce] = true; + emit UserNonceUsed(beneficiary, userNonce); } /*////////////////////////////////////////////////////////////// diff --git a/scripts/deployRewardsManager.ts b/scripts/deployRewardsManager.ts index 999ed81..c38ba73 100644 --- a/scripts/deployRewardsManager.ts +++ b/scripts/deployRewardsManager.ts @@ -1,13 +1,13 @@ import { ethers, upgrades } from 'hardhat'; /** - * Deploy RewardsRouter (one router, many servers) and beacon. + * Deploy RewardsRouter (one router, many servers). * - * 1. Deploy RewardsServer implementation (for beacon) - * 2. Deploy RewardsRouter (UUPS proxy), initialize with (dev, manager) - * 3. initializeBeacons(rewardsServerImpl) + * 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 can call router.deployServer(serverId, serverAdmin) to create a server. + * 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 @@ -24,9 +24,8 @@ async function main() { console.log('========================================\n'); const devWallet = deployer.address; - const managerWallet = deployer.address; - // 1. Deploy RewardsServer implementation (no proxy - used as beacon implementation) + // 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(); @@ -34,25 +33,19 @@ async function main() { const rewardsServerImplAddress = await rewardsServerImpl.getAddress(); console.log(' RewardsServer impl:', rewardsServerImplAddress); - // 2. Deploy RewardsRouter (UUPS proxy) + // 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, managerWallet], - { kind: 'uups', initializer: 'initialize' } - ); + 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); - - // 3. Initialize beacon - console.log('\n3. Initializing rewards server beacon...'); - const tx = await router.initializeBeacons(rewardsServerImplAddress); - await tx.wait(); - console.log(' Beacon initialized.'); + console.log(' serverBeacon (owner: dev):', await router.serverBeacon()); console.log('\n========================================'); console.log('Deployment complete.'); diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts index b20e106..0eb942f 100644 --- a/test/rewardsManager.test.ts +++ b/test/rewardsManager.test.ts @@ -15,19 +15,20 @@ describe('RewardsRouter', function () { 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, managerWallet.address], + [devWallet.address, await rewardsServerImpl.getAddress()], { kind: 'uups', initializer: 'initialize' } ); await manager.waitForDeployment(); - const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); - const rewardsServerImpl = await RewardsServerImpl.deploy(); - await rewardsServerImpl.waitForDeployment(); - - await manager.connect(devWallet).initializeBeacons(await rewardsServerImpl.getAddress()); + const MANAGER_ROLE = await manager.MANAGER_ROLE(); + await manager.connect(devWallet).grantRole(MANAGER_ROLE, managerWallet.address); return { manager, @@ -58,45 +59,28 @@ describe('RewardsRouter', function () { return { ...base, server, mockERC20 }; } - describe('initializeBeacons', function () { - it('DEV_CONFIG_ROLE can call initializeBeacons with non-zero implementation', async function () { - const [devWallet, managerWallet] = await ethers.getSigners(); - const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); - const router = await upgrades.deployProxy( - RewardsRouter, - [devWallet.address, managerWallet.address], - { kind: 'uups', initializer: 'initialize' } - ); - await router.waitForDeployment(); - const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); - const impl = await RewardsServerImpl.deploy(); - await impl.waitForDeployment(); - const implAddress = await impl.getAddress(); - expect(await router.serverBeacon()).to.equal(ethers.ZeroAddress); - await router.connect(devWallet).initializeBeacons(implAddress); - expect(await router.serverBeacon()).to.not.equal(ethers.ZeroAddress); + 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('reverts with AddressIsZero when implementation is zero', async function () { - const [devWallet, managerWallet] = await ethers.getSigners(); + 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 router = await upgrades.deployProxy( + const router2 = await upgrades.deployProxy( RewardsRouter, - [devWallet.address, managerWallet.address], + [devWallet.address, await impl2.getAddress()], { kind: 'uups', initializer: 'initialize' } ); - await router.waitForDeployment(); - await expect(router.connect(devWallet).initializeBeacons(ethers.ZeroAddress)) - .to.be.revertedWithCustomError(router, 'AddressIsZero'); - }); - - it('reverts with BeaconsAlreadyInitialized when called twice', async function () { - const { manager, devWallet, managerWallet } = await loadFixture(deployRewardsManagerFixture); - const RewardsServerImpl = await ethers.getContractFactory('RewardsServer'); - const impl2 = await RewardsServerImpl.deploy(); - await impl2.waitForDeployment(); - await expect(manager.connect(devWallet).initializeBeacons(await impl2.getAddress())) - .to.be.revertedWithCustomError(manager, 'BeaconsAlreadyInitialized'); + await router2.waitForDeployment(); + const beacon2Address = await router2.serverBeacon(); + await manager.connect(devWallet).setServerBeacon(beacon2Address); + expect(await manager.serverBeacon()).to.equal(beacon2Address); }); }); @@ -122,18 +106,6 @@ describe('RewardsRouter', function () { .to.be.revertedWithCustomError(manager, 'InvalidServerId'); }); - it('reverts with BeaconNotInitialized when beacons not set', async function () { - const [devWallet, managerWallet] = await ethers.getSigners(); - const RewardsRouter = await ethers.getContractFactory('RewardsRouter'); - const router = await upgrades.deployProxy( - RewardsRouter, - [devWallet.address, managerWallet.address], - { kind: 'uups', initializer: 'initialize' } - ); - await router.waitForDeployment(); - await expect(router.connect(managerWallet).deployServer(SERVER_ID, devWallet.address)) - .to.be.revertedWithCustomError(router, 'BeaconNotInitialized'); - }); }); // RewardsFactory ownership tests removed: deployment is now handled directly by RewardsManager.deployServer. @@ -228,7 +200,7 @@ describe('RewardsRouter', function () { 0 ); const before = await mockERC20.balanceOf(user1.address); - await manager.connect(user1).claim(SERVER_ID, data, signature); + await manager.connect(user1).claim(data, signature); const after_ = await mockERC20.balanceOf(user1.address); expect(after_ - before).to.equal(ethers.parseEther('10')); }); @@ -263,7 +235,7 @@ describe('RewardsRouter', function () { 0 ); const before = await ethers.provider.getBalance(user1.address); - const tx = await manager.connect(user1).claim(SERVER_ID, data, signature); + 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); @@ -296,7 +268,7 @@ describe('RewardsRouter', function () { [tokenId], 0 ); - await manager.connect(user1).claim(SERVER_ID, data, signature); + await manager.connect(user1).claim(data, signature); const extraEth = ethers.parseEther('0.3'); const serverAddr = await manager.getServer(SERVER_ID); await managerWallet.sendTransaction({ @@ -350,8 +322,8 @@ describe('RewardsRouter', function () { [tokenId], 1 ); - await manager.connect(user1).claim(SERVER_ID, data1, sig1); - await manager.connect(user1).claim(SERVER_ID, data2, sig2); + 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')); }); @@ -383,7 +355,7 @@ describe('RewardsRouter', function () { 0 ); const before = await mockERC20.balanceOf(user1.address); - await manager.connect(user2).claim(SERVER_ID, data, signature); + await manager.connect(user2).claim(data, signature); const after_ = await mockERC20.balanceOf(user1.address); expect(after_ - before).to.equal(ethers.parseEther('10')); }); @@ -427,7 +399,7 @@ describe('RewardsRouter', function () { [tokenId], 0 ); - await manager.connect(user1).claim(SERVER_ID, data0, sig0); + await manager.connect(user1).claim(data0, sig0); expect(await mockERC721.ownerOf(0)).to.equal(user1.address); const { data: data1, signature: sig1 } = await buildClaimDataAndSignature( serverAddr, @@ -437,7 +409,7 @@ describe('RewardsRouter', function () { [tokenId], 1 ); - await manager.connect(user1).claim(SERVER_ID, data1, sig1); + await manager.connect(user1).claim(data1, sig1); expect(await mockERC721.ownerOf(1)).to.equal(user1.address); }); }); @@ -481,7 +453,7 @@ describe('RewardsRouter', function () { [rewardTokenId], 0 ); - await manager.connect(user1).claim(SERVER_ID, data, signature); + await manager.connect(user1).claim(data, signature); expect(await mockERC1155.balanceOf(user1.address, erc1155TokenId)).to.equal(10); }); }); @@ -605,10 +577,12 @@ describe('RewardsRouter', function () { 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(SERVER_ID, data, signature)) - .to.be.revertedWithCustomError(manager, 'EnforcedPause'); + await expect(manager.connect(user1).claim(data, signature)).to.be.revertedWithCustomError( + manager, + 'EnforcedPause' + ); await manager.connect(managerWallet).unpause(); - await manager.connect(user1).claim(SERVER_ID, data, signature); + await manager.connect(user1).claim(data, signature); expect(await mockERC20.balanceOf(user1.address)).to.equal(ethers.parseEther('10')); }); }); @@ -666,7 +640,7 @@ describe('RewardsRouter', function () { 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(SERVER_ID, data, signature); + 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); @@ -742,9 +716,11 @@ describe('RewardsRouter', function () { }; 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(SERVER_ID, data, signature); - await expect(manager.connect(user1).claim(SERVER_ID, data, signature)) - .to.be.revertedWithCustomError(server, 'NonceAlreadyUsed'); + 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 () { @@ -760,8 +736,10 @@ describe('RewardsRouter', function () { }; 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(SERVER_ID, data, signature)) - .to.be.revertedWithCustomError(server, 'InvalidSignature'); + 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 () { @@ -776,12 +754,19 @@ describe('RewardsRouter', function () { ], }; await server.connect(managerWallet).createTokenAndReserveRewards(rewardToken, { value: 0 }); - const { data, signature } = await buildClaimDataAndSignature(await server.getAddress(), SERVER_ID, managerWallet, user1.address, [tokenId], 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); - await expect(manager.connect(user1).claim(2, data, signature)) - .to.be.revertedWithCustomError(server2, 'InvalidInput'); + 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 () { @@ -809,8 +794,10 @@ describe('RewardsRouter', function () { ) ); const signature = await managerWallet.signMessage(ethers.getBytes(messageHash)); - await expect(manager.connect(user1).claim(SERVER_ID, data, signature)) - .to.be.revertedWithCustomError(server, 'InvalidInput'); + await expect(manager.connect(user1).claim(data, signature)).to.be.revertedWithCustomError( + server, + 'InvalidInput' + ); }); }); From 113b3acbdf9b1be2cea5134c7efb6f23fb6306e0 Mon Sep 17 00:00:00 2001 From: Daniel Lima Date: Thu, 26 Feb 2026 12:12:06 -0300 Subject: [PATCH 9/9] Feat: Split tests and increase coverage --- .../upgradeables/soulbounds/RewardsRouter.sol | 4 +- .../upgradeables/soulbounds/RewardsServer.sol | 1 - ...wardsManager.ts => deployRewardsServer.ts} | 0 test/rewardsManager.test.ts | 857 ---------- test/rewardsRouter.test.ts | 424 +++++ test/rewardsServer.test.ts | 1438 +++++++++++++++++ 6 files changed, 1865 insertions(+), 859 deletions(-) rename scripts/{deployRewardsManager.ts => deployRewardsServer.ts} (100%) delete mode 100644 test/rewardsManager.test.ts create mode 100644 test/rewardsRouter.test.ts create mode 100644 test/rewardsServer.test.ts diff --git a/contracts/upgradeables/soulbounds/RewardsRouter.sol b/contracts/upgradeables/soulbounds/RewardsRouter.sol index 7432adb..f1f295e 100644 --- a/contracts/upgradeables/soulbounds/RewardsRouter.sol +++ b/contracts/upgradeables/soulbounds/RewardsRouter.sol @@ -110,7 +110,7 @@ contract RewardsRouter is /// @notice Authorizes the upgrade of the RewardsRouter implementation. Only UPGRADER_ROLE. function _authorizeUpgrade( - address newImplementation + address /* newImplementation */ ) internal override onlyRole(UPGRADER_ROLE) {} /*////////////////////////////////////////////////////////////// @@ -146,6 +146,8 @@ contract RewardsRouter is /// @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(); diff --git a/contracts/upgradeables/soulbounds/RewardsServer.sol b/contracts/upgradeables/soulbounds/RewardsServer.sol index d8fb173..3acadd4 100644 --- a/contracts/upgradeables/soulbounds/RewardsServer.sol +++ b/contracts/upgradeables/soulbounds/RewardsServer.sol @@ -55,7 +55,6 @@ contract RewardsServer is Initializable, AccessControlUpgradeable, ERC721HolderU error InsufficientBalance(); error InsufficientERC721Ids(); error ExceedMaxSupply(); - error ClaimRewardPaused(); error InvalidInput(); error DupTokenId(); error TransferFailed(); diff --git a/scripts/deployRewardsManager.ts b/scripts/deployRewardsServer.ts similarity index 100% rename from scripts/deployRewardsManager.ts rename to scripts/deployRewardsServer.ts diff --git a/test/rewardsManager.test.ts b/test/rewardsManager.test.ts deleted file mode 100644 index 0eb942f..0000000 --- a/test/rewardsManager.test.ts +++ /dev/null @@ -1,857 +0,0 @@ -import { expect } from 'chai'; -import { ethers, upgrades } from 'hardhat'; -import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; - -/** - * RewardsRouter + RewardsServer tests. - * 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, - }; - } - - 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 }; - } - - 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'); - }); - - }); - - // RewardsFactory ownership tests removed: deployment is now handled directly by RewardsManager.deployServer. - - 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'); - }); - }); - - /** 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('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('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 { 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 }); - 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 { 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(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')); - }); - }); - - 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')); - }); - }); - - 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'); - }); - }); - - 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'); - }); - }); - }); -}); 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); + }); + }); +}); +