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: xp nfts contract skeleton #164

Merged
merged 12 commits into from
Aug 13, 2024
180 changes: 180 additions & 0 deletions packages/zevm-app-contracts/contracts/xp-nft/xpNFT.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract ZetaXP is ERC721URIStorage, Ownable {
/* An ECDSA signature. */
struct Signature {
uint8 v;
bytes32 r;
bytes32 s;
}

struct Task {
bool completed;
uint256 count;
}

struct Data {
uint256 xpTotal;
uint256 level;
uint256 testnetCampaignParticipant;
uint256 enrollDate;
uint256 mintDate;
uint256 generation;
}

mapping(uint256 => Data) public data;
andresaiello marked this conversation as resolved.
Show resolved Hide resolved
mapping(uint256 => mapping(uint256 => Task)) public tasks;
andresaiello marked this conversation as resolved.
Show resolved Hide resolved

// Base URL for NFT images
string public baseTokenURI;
address public signerAddress;

// Event for New Mint
event NewNFTMinted(address indexed sender, uint256 indexed tokenId);
// Event for NFT Update
event NFTUpdated(address indexed sender, uint256 indexed tokenId);

error InvalidSigner();
error LengthMismatch();

constructor(
string memory name,
string memory symbol,
string memory baseTokenURI_,
address signerAddress_

Check notice

Code scanning / Slither

Missing zero address validation Low

) ERC721(name, symbol) {
baseTokenURI = baseTokenURI_;
signerAddress = signerAddress_;
}

// The following functions are overrides required by Solidity.

function tokenURI(uint256 tokenId) public view override(ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}

function supportsInterface(bytes4 interfaceId) public view override(ERC721URIStorage) returns (bool) {
return super.supportsInterface(interfaceId);
}

// Helper function to convert uint to string
function _uint2str(uint _i) internal pure returns (string memory _uintAsString) {
if (_i == 0) {
return "0";
}
uint j = _i;
uint len;
while (j != 0) {
len++;
j /= 10;
}
bytes memory bstr = new bytes(len);
uint k = len;
while (_i != 0) {
k = k - 1;
uint8 temp = (uint8(48 + (_i % 10)));
bstr[k] = bytes1(temp);
_i /= 10;
}
return string(bstr);
}

function _verify(
address to,
uint256 tokenId,
Data memory data_,
uint256[] calldata taskIds,
Task[] calldata taskValues,
Signature calldata signature
) private view {
bytes32 payloadHash = _calculateHash(to, tokenId, data_, taskIds, taskValues);
bytes32 messageHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", payloadHash));

address messageSigner = ecrecover(messageHash, signature.v, signature.r, signature.s);

if (signerAddress != messageSigner) revert InvalidSigner();
}

// Function to compute the hash of the data and tasks for a token
function _calculateHash(
address to,
uint256 tokenId,
Data memory data_,
uint256[] memory taskIds,
Task[] memory taskValues
) private pure returns (bytes32) {
bytes memory encodedData = abi.encode(
to,
tokenId,
data_.xpTotal,
data_.level,
data_.testnetCampaignParticipant,
data_.enrollDate,
data_.mintDate,
data_.generation
);

for (uint256 i = 0; i < taskIds.length; i++) {
encodedData = abi.encode(encodedData, taskIds[i], taskValues[i].completed, taskValues[i].count);
}

return keccak256(encodedData);
}

function _updateNFT(
address to,
uint256 tokenId,
Data memory data_,
uint256[] calldata taskIds,
Task[] calldata taskValues,
Signature calldata signature
) internal {
_verify(to, tokenId, data_, taskIds, taskValues, signature);
if (taskIds.length != taskValues.length) revert LengthMismatch();

data[tokenId] = data_;
for (uint256 i = 0; i < taskIds.length; i++) {
tasks[tokenId][taskIds[i]] = taskValues[i];
}
}

// External mint function
function mintNFT(
address to,
uint256 tokenId,
Data memory data_,
uint256[] calldata taskIds,
Task[] calldata taskValues,
Signature calldata signature
) external {
_mint(to, tokenId);
_setTokenURI(tokenId, string(abi.encodePacked(baseTokenURI, _uint2str(tokenId))));

_updateNFT(to, tokenId, data_, taskIds, taskValues, signature);

emit NewNFTMinted(to, tokenId);
}

// External mint function
function updateNFT(
uint256 tokenId,
Data memory data_,
uint256[] calldata taskIds,
Task[] calldata taskValues,
Signature calldata signature
) external {
address owner = ownerOf(tokenId);
_updateNFT(owner, tokenId, data_, taskIds, taskValues, signature);

emit NFTUpdated(owner, tokenId);
}

// Set the base URI for tokens
function setBaseURI(string calldata _uri) external onlyOwner {
Dismissed Show dismissed Hide dismissed
andresaiello marked this conversation as resolved.
Show resolved Hide resolved
baseTokenURI = _uri;
}
}
61 changes: 61 additions & 0 deletions packages/zevm-app-contracts/test/xp-nft/test.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { ethers } from "hardhat";

export interface Task {
completed: boolean;
count: number;
}

export interface Data {
enrollDate: number;
generation: number;
level: number;
mintDate: number;
testnetCampaignParticipant: number;
xpTotal: number;
}

export interface NFT {
data: Data;
tasks: Task[];
tasksId: number[];
to: string;
tokenId: number;
}

export const getSignature = async (
signer: SignerWithAddress,
to: string,
tokenId: number,
data: Data,
tasksId: number[],
tasks: Task[]
) => {
let payload = ethers.utils.defaultAbiCoder.encode(
["address", "uint256", "uint256", "uint256", "uint256", "uint256", "uint256", "uint256"],
[
to,
tokenId,
data.xpTotal,
data.level,
data.testnetCampaignParticipant,
data.enrollDate,
data.mintDate,
data.generation,
]
);

let combinedPayload = payload;
for (let i = 0; i < tasksId.length; i++) {
payload = ethers.utils.defaultAbiCoder.encode(
["bytes", "uint256", "bool", "uint256"],
[payload, tasksId[i], tasks[i].completed, tasks[i].count]
);
}

const payloadHash = ethers.utils.keccak256(payload);

// This adds the message prefix
const signature = await signer.signMessage(ethers.utils.arrayify(payloadHash));
return ethers.utils.splitSignature(signature);
};
152 changes: 152 additions & 0 deletions packages/zevm-app-contracts/test/xp-nft/xp-nft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { expect, use } from "chai";
import { solidity } from "ethereum-waffle";
use(solidity);
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { ethers } from "hardhat";

import { ZetaXP } from "../../typechain-types";
import { getSignature, NFT } from "./test.helpers";

describe("XP NFT Contract test", () => {
let zetaXP: ZetaXP, inviter: SignerWithAddress, invitee: SignerWithAddress, addrs: SignerWithAddress[];
let sampleNFT: NFT;

beforeEach(async () => {
[inviter, invitee, ...addrs] = await ethers.getSigners();
const zetaXPFactory = await ethers.getContractFactory("ZetaXP");

//@ts-ignore
zetaXP = await zetaXPFactory.deploy("ZETA NFT", "ZNFT", "https://api.zetachain.io/nft/", inviter.address);

sampleNFT = {
data: {
enrollDate: 5435,
generation: 2314,
level: 7,
mintDate: 34,
testnetCampaignParticipant: 2,
xpTotal: 67,
},
tasks: [
{
completed: true,
count: 10,
},
{
completed: false,
count: 5,
},
],
tasksId: [2, 3],
to: invitee.address,
tokenId: 1,
};
});

describe("True", () => {
it("Should be true", async () => {
expect(true).to.equal(true);
});
});
andresaiello marked this conversation as resolved.
Show resolved Hide resolved

const validateNFT = async (nft: NFT) => {
const owner = await zetaXP.ownerOf(nft.tokenId);
await expect(owner).to.be.eq(nft.to);

const nftData = await zetaXP.data(nft.tokenId);
await expect(nftData.xpTotal).to.be.eq(nft.data.xpTotal);
await expect(nftData.level).to.be.eq(nft.data.level);
await expect(nftData.testnetCampaignParticipant).to.be.eq(nft.data.testnetCampaignParticipant);
await expect(nftData.enrollDate).to.be.eq(nft.data.enrollDate);
await expect(nftData.mintDate).to.be.eq(nft.data.mintDate);
await expect(nftData.generation).to.be.eq(nft.data.generation);

for (let i = 0; i < nft.tasksId.length; i++) {
const sampleTask = nft.tasks[i];
const task = await zetaXP.tasks(nft.tokenId, nft.tasksId[i]);
await expect(task.completed).to.be.eq(sampleTask.completed);
await expect(task.count).to.be.eq(sampleTask.count);
}

const url = await zetaXP.tokenURI(nft.tokenId);
await expect(url).to.be.eq(`https://api.zetachain.io/nft/${nft.tokenId}`);
};

describe("NFT test", () => {
it("Should mint an NFT", async () => {
const sig = await getSignature(inviter, invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks);
await zetaXP.mintNFT(invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks, sig);

await validateNFT(sampleNFT);
});

it("Should emit event on minting", async () => {
const sig = await getSignature(inviter, invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks);
const tx = zetaXP.mintNFT(invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks, sig);
await expect(tx).to.emit(zetaXP, "NewNFTMinted").withArgs(invitee.address, 1);
});

it("Should revert if signature it's not correct", async () => {
const sig = await getSignature(addrs[0], invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks);
const tx = zetaXP.mintNFT(invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks, sig);
await expect(tx).to.be.revertedWith("InvalidSigner");
});

it("Should update NFT", async () => {
const sig = await getSignature(inviter, invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks);
await zetaXP.mintNFT(invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks, sig);

const updatedSampleNFT = {
data: {
enrollDate: 5435,
generation: 2314,
level: 7,
mintDate: 34,
testnetCampaignParticipant: 2,
xpTotal: 100,
},
tasks: [
{
completed: true,
count: 10,
},
{
completed: true,
count: 5,
},
],
tasksId: [2, 3],
to: invitee.address,
tokenId: 1,
};

const updatedSig = await getSignature(
inviter,
invitee.address,
1,
updatedSampleNFT.data,
updatedSampleNFT.tasksId,
updatedSampleNFT.tasks
);
await zetaXP.updateNFT(1, updatedSampleNFT.data, updatedSampleNFT.tasksId, updatedSampleNFT.tasks, updatedSig);

validateNFT(updatedSampleNFT);
});
});

it("Should update base url", async () => {
await zetaXP.setBaseURI("https://api.zetachain.io/nft/v2/");
const url = await zetaXP.baseTokenURI();
await expect(url).to.be.eq("https://api.zetachain.io/nft/v2/");

const sig = await getSignature(inviter, invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks);
await zetaXP.mintNFT(invitee.address, 1, sampleNFT.data, sampleNFT.tasksId, sampleNFT.tasks, sig);
const tokenURI = await zetaXP.tokenURI(1);
await expect(tokenURI).to.be.eq("https://api.zetachain.io/nft/v2/1");
});

it("Should revert if not owner want to update base url", async () => {
const tx = zetaXP.connect(addrs[0]).setBaseURI("https://api.zetachain.io/nft/v2/");
expect(tx).to.be.revertedWith("Ownable: caller is not the owner");
});
});
Loading