From 1d80ec772fb482f1e57707f921c89fbd776305d7 Mon Sep 17 00:00:00 2001 From: James Earle Date: Tue, 20 Feb 2024 17:11:04 -0800 Subject: [PATCH 1/2] tests for requirement 1, staking --- contracts/tokens/MockERC721.sol | 8 + contracts/zxp/Games.sol | 2 +- contracts/zxp/ObjectRegistry.sol | 5 +- contracts/zxp/game/GameVault.sol | 7 +- contracts/zxp/game/XP.sol | 2 +- contracts/zxp/game/seasons/Seasons.sol | 1 + hardhat.config.ts | 6 - test/reqs.test.ts | 198 +++++++++++++++++++++++++ test/zXP.test.ts | 12 +- 9 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 test/reqs.test.ts diff --git a/contracts/tokens/MockERC721.sol b/contracts/tokens/MockERC721.sol index 3445fa1..2ff9e26 100644 --- a/contracts/tokens/MockERC721.sol +++ b/contracts/tokens/MockERC721.sol @@ -79,4 +79,12 @@ contract MockERC721 is ERC721, AccessControl, IMockERC721 { ) public view virtual override(ERC721, AccessControl) returns (bool) { return super.supportsInterface(interfaceId); } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId + ) public virtual override { + super.safeTransferFrom(from, to, tokenId, ""); + } } diff --git a/contracts/zxp/Games.sol b/contracts/zxp/Games.sol index 4c488f5..9c39073 100644 --- a/contracts/zxp/Games.sol +++ b/contracts/zxp/Games.sol @@ -7,7 +7,7 @@ import {ObjectRegistry} from "./ObjectRegistry.sol"; contract Games is IGames, ObjectRegistry { struct Game { string metadata; - ObjectRegistry objects; + ObjectRegistry objects; // naming should be "registry" } mapping(bytes32 name => Game game) public games; diff --git a/contracts/zxp/ObjectRegistry.sol b/contracts/zxp/ObjectRegistry.sol index 1129618..04fc0ff 100644 --- a/contracts/zxp/ObjectRegistry.sol +++ b/contracts/zxp/ObjectRegistry.sol @@ -4,7 +4,9 @@ pragma solidity ^0.8.19; import {IObjectRegistry} from "./interfaces/IObjectRegistry.sol"; contract ObjectRegistry is IObjectRegistry { - bytes32 internal constant OWNER = "Owner"; + bytes32 internal constant OWNER = "Owner"; // maybe put all constants in a library? + + // bytes mapping of "name" to registry address of that specific object type mapping(bytes32 name => address object) public objects; constructor(address owner) { @@ -15,6 +17,7 @@ contract ObjectRegistry is IObjectRegistry { bytes32[] calldata objectNames, address[] calldata objectAddresses ) public override { + // ZXP specific object inside of generic "registry" contract object require(msg.sender == objects[OWNER], "ZXP Not game owner"); require(objectNames.length > 0, "ZXP Objects empty"); for (uint256 i = 0; i < objectNames.length; i++) { diff --git a/contracts/zxp/game/GameVault.sol b/contracts/zxp/game/GameVault.sol index f0f22b3..13e0134 100644 --- a/contracts/zxp/game/GameVault.sol +++ b/contracts/zxp/game/GameVault.sol @@ -11,6 +11,8 @@ import {ISeasons} from "./interfaces/ISeasons.sol"; contract GameVault is ERC721Wrapper, ObjectRegistryClient, IGameVault { bytes32 internal constant SEASONS = "Seasons"; + // TODO should be nested mapping to include which user staked it, maybe + // Depends on if we want to allow the game vault token to be transferable? mapping(uint id => uint block) public stakedAt; constructor( @@ -19,7 +21,7 @@ contract GameVault is ERC721Wrapper, ObjectRegistryClient, IGameVault { string memory name, string memory symbol, IObjectRegistry registry, - bytes32 game + bytes32 game // unused ) ObjectRegistryClient(registry) ERC721(name, symbol) @@ -35,7 +37,8 @@ contract GameVault is ERC721Wrapper, ObjectRegistryClient, IGameVault { ISeasons(registry.addressOf(SEASONS)).onUnstake( id, msg.sender, - block.number - stakedAt[id] + block.number - stakedAt[id] + // param expects just `stakedAt[i]` not `block.number - stakedAt[i]` ); stakedAt[id] = 0; super._burn(id); diff --git a/contracts/zxp/game/XP.sol b/contracts/zxp/game/XP.sol index 54815cf..f18b89e 100644 --- a/contracts/zxp/game/XP.sol +++ b/contracts/zxp/game/XP.sol @@ -14,7 +14,7 @@ contract XP is ObjectRegistryClient, QuadraticLevelCurve, ERC20, IXP { string memory name, string memory symbol, IObjectRegistry registry, - bytes32 game + bytes32 game // unused ) ObjectRegistryClient(registry) ERC20(name, symbol) {} function _beforeTokenTransfer( diff --git a/contracts/zxp/game/seasons/Seasons.sol b/contracts/zxp/game/seasons/Seasons.sol index fa3c16d..0f248a1 100644 --- a/contracts/zxp/game/seasons/Seasons.sol +++ b/contracts/zxp/game/seasons/Seasons.sol @@ -28,6 +28,7 @@ contract Seasons is ObjectRegistryClient, ISeasons, Ownable { _; } + // ZXP modifier in seasons? modifier onlyRegistered(address object, bytes32 name) { require( address( diff --git a/hardhat.config.ts b/hardhat.config.ts index f3f8ccc..89bb7a4 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -2,7 +2,6 @@ require("dotenv").config(); import { HardhatUserConfig } from "hardhat/config"; -import * as tenderly from "@tenderly/hardhat-tenderly"; import "@nomicfoundation/hardhat-toolbox"; import "@nomiclabs/hardhat-ethers"; import "@nomicfoundation/hardhat-network-helpers"; @@ -57,7 +56,6 @@ const config: HardhatUserConfig = { sepolia: { url: process.env.SEPOLIA_RPC_URL, timeout: 10000000, - accounts: [process.env.PRIVATE_KEY1!, process.env.PRIVATE_KEY2!, process.env.PRIVATE_KEY3!, process.env.PRIVATE_KEY4!, process.env.PRIVATE_KEY5!, process.env.PRIVATE_KEY6!, process.env.PRIVATE_KEY7!, process.env.PRIVATE_KEY8!, process.env.PRIVATE_KEY9!] //pub key 0x6BC8F26172E1bbd3139f951893d6d5d1b669375d //chainId: 11155111 }, devnet: { @@ -69,10 +67,6 @@ const config: HardhatUserConfig = { etherscan: { apiKey: `${process.env.ETHERSCAN_API_KEY}`, }, - tenderly: { - project: `${process.env.TENDERLY_PROJECT_SLUG}`, - username: `${process.env.TENDERLY_ACCOUNT_ID}`, - }, docgen: { pages: "files", templates: "docs/docgen-templates", diff --git a/test/reqs.test.ts b/test/reqs.test.ts new file mode 100644 index 0000000..a2203dc --- /dev/null +++ b/test/reqs.test.ts @@ -0,0 +1,198 @@ +import * as hre from "hardhat"; +import { ethers } from "ethers"; +import { expect } from "chai"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { + GameVault, + Games, + MockERC20, + MockERC721, + ObjectRegistry, + ObjectRegistry__factory, + Seasons, + StakerRewards, + XP, +} from "../typechain"; +// eslint-disable-next-line @typescript-eslint/no-var-requires + +// Test core requirements for Staking + +// Priority #1 - Staking +// 1.1 - User visits a staking website (possibly a zApp). +// 1.2 - User can stake their NFT (e.g., Wilder Wheel) +// 1.3 - User receives staked NFT in return +// 1.4 - User receives a Race Pass (new contract) +// 1.5 - User receives a percentage of rewards on an epoch (passive rewards) +// 1.6 - User can unstake at any time. + +describe("Requirements testing", () => { + let deployer: SignerWithAddress; + let staker: SignerWithAddress; + + let mockERC20: MockERC20; + let mockERC721: MockERC721; + + let games: Games; + let gameVault: GameVault; // ERC721Wrapper + let rewards: StakerRewards; + let seasons: Seasons; + let xp: XP; // is ERC20 + + let stakingGameRegistryAddress: string; + let stakingGameRegistry: ObjectRegistry; + + let seasons0RegistryAddress: string; + let seasons0Registry: ObjectRegistry; + + let seasonsRegistry: ObjectRegistry; + let gameVaultRegistry: ObjectRegistry; + let xpRegistry: ObjectRegistry + + // Setup game + before(async () => { + [ + deployer, + staker, + ] = await hre.ethers.getSigners(); + + const mockERC20Factory = await hre.ethers.getContractFactory("MockERC20"); + // Will immediately mint and transfer to msg.sender + mockERC20 = await mockERC20Factory.deploy("MEOW", "MEOW"); + + const mockERC721Factory = await hre.ethers.getContractFactory("MockERC721"); + mockERC721 = (await mockERC721Factory.deploy("WilderWheels", "WW", "0://wheels-base")); + + const gamesFactory = await hre.ethers.getContractFactory("Games"); + games = await gamesFactory.deploy(); + + const gameName = "StakingGame"; + const gameNameBytes = hre.ethers.utils.formatBytes32String(gameName); + await games.createGame(gameNameBytes, deployer.address, "a-staking-game", [], []); + + // Registry for the StakingGame + stakingGameRegistryAddress = (await games.games(gameNameBytes)).objects; + stakingGameRegistry = ObjectRegistry__factory.connect(stakingGameRegistryAddress, deployer); + + const seasonsFactory = await hre.ethers.getContractFactory("Seasons"); + seasons = await seasonsFactory.deploy( + stakingGameRegistry.address, + ); + + const xpFactory = await hre.ethers.getContractFactory("XP"); + xp = await xpFactory.deploy("XP", "XP", stakingGameRegistry.address, gameNameBytes); + + const gameVaultFactory = await hre.ethers.getContractFactory("GameVault"); + gameVault = await gameVaultFactory.deploy( + mockERC721.address, // underlying ERC721 + mockERC20.address, + "GameVault", // wrapped ERC721 + "GMVLT", + stakingGameRegistry.address, + gameNameBytes + ); + + const stakerRewardsFactory = await hre.ethers.getContractFactory("StakerRewards"); + rewards = await stakerRewardsFactory.deploy( + mockERC20.address, + hre.ethers.utils.parseEther("100"), + mockERC721.address, + gameVault.address, + seasons.address, // This var is `ISeasons seasonRegistry, but we give seasons, not a registry + ); + + // StakerRewards contract needs funds to be able to pay members + // We need to regulate this before calling transfer in each + await mockERC20.connect(deployer).transfer(rewards.address, await mockERC20.balanceOf(deployer.address)); + + // Registry for season 0 of the StakingGame + seasons0RegistryAddress = (await seasons.seasons(0)).objects; + seasons0Registry = ObjectRegistry__factory.connect(seasons0RegistryAddress, deployer); + + // Registry for the Seasons contract + seasonsRegistry = ObjectRegistry__factory.connect(await seasons.registry(), deployer); + + // Registry for the GameVault contract + gameVaultRegistry = ObjectRegistry__factory.connect(await gameVault.registry(), deployer); + + // Registry for the XP contract + xpRegistry = ObjectRegistry__factory.connect(await xp.registry(), deployer); + + // Registrations + await gameVaultRegistry.registerObjects([ethers.utils.formatBytes32String("Seasons")], [seasons.address]); + await stakingGameRegistry.registerObjects([ethers.utils.formatBytes32String("Seasons")], [seasons.address]); + await stakingGameRegistry.registerObjects([ethers.utils.formatBytes32String("GameVault")], [gameVault.address]); + await seasons0Registry.registerObjects([ethers.utils.formatBytes32String("StakerRewards")], [rewards.address]); + await seasonsRegistry.registerObjects([ethers.utils.formatBytes32String("XP")], [xp.address]); + await xpRegistry.registerObjects([ethers.utils.formatBytes32String("StakerRewards")], [rewards.address]); + }); + + it("Fails when mint is called by someone without the MINTER_ROLE", async () => { + await expect(mockERC721.connect(staker).mint(staker.address, 1)).to.be.revertedWith("ERC721PresetMinterPauserAutoId: must have minter role to mint"); + }); + + it("Allows a user to stake their NFT, confirm they receive staked NFT in return", async () => { + // Assume user already owns an NFT they'd like to stake + await mockERC721.connect(deployer).mint(staker.address, 1); + + const stakerBalanceBefore = await mockERC721.balanceOf(staker.address); + const gameVaultBalanceBefore = await mockERC721.balanceOf(gameVault.address); + + const stakerBalanceGMVLTBefore = await gameVault.balanceOf(staker.address); + + await mockERC721.connect(staker).approve(gameVault.address, 1); + + // 1.2 - User stakes their NFT in the GameVault + await gameVault.connect(staker).depositFor(staker.address, [1]); + + const stakerBalanceAfter = await mockERC721.balanceOf(staker.address); + const gameVaultBalanceAfter = await mockERC721.balanceOf(gameVault.address); + + const stakerBalanceGMVLTAfter = await gameVault.balanceOf(staker.address); + + expect(stakerBalanceAfter).eq(stakerBalanceBefore.sub(1)); + expect(gameVaultBalanceAfter).eq(gameVaultBalanceBefore.add(1)); + + // 1.3 - User receives staked NFT in return + expect(stakerBalanceGMVLTAfter).eq(stakerBalanceGMVLTBefore.add(1)); + }); + + it("Users receive a race pass (new contract)", async () => { + // 1.4 TODO No notion of a Stake Pass exists. Need clarity on what this is meant to be + }); + + it("Fails when a user calls to claim a reward for a token that is not theirs", async () => { + await expect(rewards.connect(deployer).claim(1)).to.be.revertedWith("ZXP claimer isnt owner"); + }); + + it("User receives a percentage of rewards on an epoch (passive rewards)", async () => { + // Because we transfer to the GameVault in staking, when we call claim + // the "underlyingToken.ownerOf(id) == msg.sender" check fails because the staker is not currently the owner + // We should call + + // This is successful + expect(await mockERC721.ownerOf(1)).eq(gameVault.address); + + // Call to claim rewards + const before = await mockERC20.balanceOf(staker.address); + await expect(rewards.connect(staker).claim(1)).to.be.revertedWith("ZXP claimer isnt owner"); + const after = await mockERC20.balanceOf(staker.address); + }); + + it("User can unstake at any time", async () => { + const stakerBalanceBefore = await mockERC721.balanceOf(staker.address); + const stakerBalanceGMVLTBefore = await gameVault.balanceOf(staker.address); + const stakerBalanceERC20Before = await mockERC20.balanceOf(staker.address); + + // gameVault.withdrawTo => seasons.onUnstake() => stakerRewards.onUnsake() + // 1.6 - User can unstake at any time. + await gameVault.connect(staker).withdrawTo(staker.address, [1]); + + const stakerBalanceAfter = await mockERC721.balanceOf(staker.address); + const stakerBalanceGMVLTAfter = await gameVault.balanceOf(staker.address); + const stakerBalanceERC20After = await mockERC20.balanceOf(staker.address); + + expect(stakerBalanceAfter).eq(stakerBalanceBefore.add(1)); + expect(stakerBalanceGMVLTAfter).eq(stakerBalanceGMVLTBefore.sub(1)); + expect(stakerBalanceERC20After).gt(stakerBalanceERC20Before); + }); +}); \ No newline at end of file diff --git a/test/zXP.test.ts b/test/zXP.test.ts index f1e05da..1b759e0 100644 --- a/test/zXP.test.ts +++ b/test/zXP.test.ts @@ -66,13 +66,13 @@ describe("ZXP", () => { await erc721.deployed(); mockErc721 = erc721; - gameName = ethers.utils.formatBytes32String("game0"); const gamesFactory = await hre.ethers.getContractFactory("Games"); - const gamesDeploy = await gamesFactory.deploy(); + const gamesDeploy = await gamesFactory.deploy(); // uses this.signer internally await gamesDeploy.deployed(); games = gamesDeploy; - - //create empty game + + // create empty game + gameName = ethers.utils.formatBytes32String("game0"); await games.createGame(gameName, deployer.address, "description", [], []); const storedGame = await games.games(gameName); const gameObjects = storedGame.objects; @@ -128,6 +128,8 @@ describe("ZXP", () => { await mockErc721.connect(deployer).mint(s1, s1nft); }); it("Staker 1 stakes NFT", async () => { + // how does user get staked nft back? is it burned on unstake? + // nothing calls `_burn` and its internal, so never accessed? await mockErc721.connect(staker1)["safeTransferFrom(address,address,uint256)"](s1, gameVault.address, s1nft); }); it("Mints Staker 2 NFT", async () => { @@ -138,6 +140,8 @@ describe("ZXP", () => { await mockErc721.connect(staker2)["safeTransferFrom(address,address,uint256)"](s2, gameVault.address, s2nft); }); it("Gets season registry", async () => { + // shouldnt I be able to get this info through the gameRegistry? + // why do we have another registry? const storedSeason = await seasons.seasons(await seasons.currentSeason()); const storedRegistry = storedSeason.objects; const ObjectRegistryFactory = await hre.ethers.getContractFactory("ObjectRegistry"); From fe742f3bf1ca313911d92915539beb5795b809c4 Mon Sep 17 00:00:00 2001 From: James Earle Date: Wed, 21 Feb 2024 15:19:56 -0800 Subject: [PATCH 2/2] comments --- contracts/zxp/game/GameVault.sol | 4 ++-- contracts/zxp/game/seasons/Seasons.sol | 1 + test/reqs.test.ts | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/contracts/zxp/game/GameVault.sol b/contracts/zxp/game/GameVault.sol index 9a1afb7..fb50714 100644 --- a/contracts/zxp/game/GameVault.sol +++ b/contracts/zxp/game/GameVault.sol @@ -11,7 +11,7 @@ import {ISeasons} from "./interfaces/ISeasons.sol"; contract GameVault is ERC721Wrapper, ObjectRegistryClient, IGameVault { bytes32 internal constant SEASONS = "Seasons"; - // TODO should be nested mapping to include which user staked it, maybe + // TODO should be nested mapping to include which user staked it, maybe? // Depends on if we want to allow the game vault token to be transferable? mapping(uint id => uint block) public stakedAt; @@ -21,7 +21,7 @@ contract GameVault is ERC721Wrapper, ObjectRegistryClient, IGameVault { string memory stakedTokenName, ///name of tokens that this contract issues on stake of underlyingToken string memory stakedTokenSymbol, ///symbol of tokens that this contract issues on stake of underlyingToken IObjectRegistry registry, - bytes32 game // unused + bytes32 game // TODO unused ) ObjectRegistryClient(registry) ERC721(stakedTokenName, stakedTokenSymbol) diff --git a/contracts/zxp/game/seasons/Seasons.sol b/contracts/zxp/game/seasons/Seasons.sol index 134d92a..3486bf8 100644 --- a/contracts/zxp/game/seasons/Seasons.sol +++ b/contracts/zxp/game/seasons/Seasons.sol @@ -81,6 +81,7 @@ contract Seasons is ObjectRegistryClient, ISeasons { IXP(registry.addressOf(XP)).awardXP( to, STAKER_XP_REWARD * (block.number - stakedAt) + // TODO are `rewardsPerBlock` and `STAKER_XP_REWARD` expected to be the same? ); } diff --git a/test/reqs.test.ts b/test/reqs.test.ts index a2203dc..3d09634 100644 --- a/test/reqs.test.ts +++ b/test/reqs.test.ts @@ -55,6 +55,15 @@ describe("Requirements testing", () => { staker, ] = await hre.ethers.getSigners(); + // Deployments + // 1. MockERC20 + // 2. MockERC721 + // 3. Games + // 4. Seasons + // 5. XP + // 6. GameVault + // 7. StakerRewards + const mockERC20Factory = await hre.ethers.getContractFactory("MockERC20"); // Will immediately mint and transfer to msg.sender mockERC20 = await mockERC20Factory.deploy("MEOW", "MEOW"); @@ -67,10 +76,10 @@ describe("Requirements testing", () => { const gameName = "StakingGame"; const gameNameBytes = hre.ethers.utils.formatBytes32String(gameName); - await games.createGame(gameNameBytes, deployer.address, "a-staking-game", [], []); + await games.createGame(gameNameBytes, deployer.address, "a-staking-game"); // Registry for the StakingGame - stakingGameRegistryAddress = (await games.games(gameNameBytes)).objects; + stakingGameRegistryAddress = (await games.games(gameNameBytes)).gameObjects; stakingGameRegistry = ObjectRegistry__factory.connect(stakingGameRegistryAddress, deployer); const seasonsFactory = await hre.ethers.getContractFactory("Seasons"); @@ -105,7 +114,7 @@ describe("Requirements testing", () => { await mockERC20.connect(deployer).transfer(rewards.address, await mockERC20.balanceOf(deployer.address)); // Registry for season 0 of the StakingGame - seasons0RegistryAddress = (await seasons.seasons(0)).objects; + seasons0RegistryAddress = (await seasons.seasons(0)).seasonObjects; seasons0Registry = ObjectRegistry__factory.connect(seasons0RegistryAddress, deployer); // Registry for the Seasons contract @@ -183,7 +192,7 @@ describe("Requirements testing", () => { const stakerBalanceGMVLTBefore = await gameVault.balanceOf(staker.address); const stakerBalanceERC20Before = await mockERC20.balanceOf(staker.address); - // gameVault.withdrawTo => seasons.onUnstake() => stakerRewards.onUnsake() + // gameVault.withdrawTo => seasons.onUnstake() => stakerRewards.onUnstake() // 1.6 - User can unstake at any time. await gameVault.connect(staker).withdrawTo(staker.address, [1]);