Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Requirement tests for Staking #3

Open
wants to merge 3 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions contracts/tokens/MockERC721.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
}
}
5 changes: 4 additions & 1 deletion contracts/zxp/ObjectRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that may be better. This way does show you exactly which ones are being used by a contract but that can still be accomplished.


// bytes mapping of "name" to registry address of that specific object type
mapping(bytes32 name => address object) public objects;

constructor(address owner) {
Expand All @@ -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++) {
Expand Down
5 changes: 4 additions & 1 deletion contracts/zxp/game/GameVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -19,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
bytes32 game // TODO unused
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs removed

)
ObjectRegistryClient(registry)
ERC721(stakedTokenName, stakedTokenSymbol)
Expand All @@ -38,6 +40,7 @@ contract GameVault is ERC721Wrapper, ObjectRegistryClient, IGameVault {
id,
msg.sender,
block.number - wasStakedAt
// TODO param expects just `stakedAt[i]` not `block.number - stakedAt[i]`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes needs changed

);
super._burn(id);
}
Expand Down
2 changes: 1 addition & 1 deletion contracts/zxp/game/XP.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions contracts/zxp/game/seasons/Seasons.sol
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ contract Seasons is ObjectRegistryClient, ISeasons {
_;
}

// ZXP modifier in seasons?
modifier onlyRegistered(address object, bytes32 name) {
require(
address(
Expand Down Expand Up @@ -80,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?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no one is vault tokens one is XP

);
}

Expand Down
3 changes: 3 additions & 0 deletions contracts/zxp/game/seasons/mechanics/StakerRewards.sol
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,14 @@ contract StakerRewards is ObjectRegistryClient, IStakerRewards {
numBlocks = block.number - stakedAt;
}
claimedAt[id] = block.number;
// TODO should this include a zero check so we don't call unnecessarily?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, but that also unnecessarily checks for 0 every time then, where it should usually not happen that the value is actually 0.
but, checking 0 is a common approach.

rewardToken.transfer(to, rewardPerBlock * numBlocks);
}

function claim(uint id) external override {
require(
// TODO This `ownerOf` call will be incorrect
// The owner of the NFT while it is staked is the game vault, not the user
underlyingToken.ownerOf(id) == msg.sender,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes needs fixed, it needs to call the token at the GameVault address, not the underlying token

"ZXP claimer isnt owner"
);
Expand Down
6 changes: 0 additions & 6 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: {
Expand All @@ -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",
Expand Down
207 changes: 207 additions & 0 deletions test/reqs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
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();

// 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");

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes you successfully get the gameObjects here, which is the ObjectRegistry for the game you're in.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(worth pointing out as this is a complicated part of the system. it is one step removed from just inheriting the ObjectRegistry in Games.sol, the simplest and first way I did it)

stakingGameRegistryAddress = (await games.games(gameNameBytes)).gameObjects;
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)).seasonObjects;
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.onUnstake()
// 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);
});
});
12 changes: 8 additions & 4 deletions test/zXP.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.gameObjects;
Expand Down Expand Up @@ -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?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes it is burned on withdrawTo in ERC721Wrapper

// 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 () => {
Expand All @@ -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?
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the ObjectRegistrys stored in the Seasons struct.
It is a separate set of contracts stored and managed separately from the gameRegistrys set of contracts.

We're able to make different rules on this set of contracts for how they can be updated and added.

const storedSeason = await seasons.seasons(await seasons.currentSeason());
const storedRegistry = storedSeason.seasonObjects;
const ObjectRegistryFactory = await hre.ethers.getContractFactory("ObjectRegistry");
Expand Down