Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
test(VanguardNFT): provide tests and measure gas fees
Browse files Browse the repository at this point in the history
shenshin committed Dec 6, 2024

Verified

This commit was signed with the committer’s verified signature.
akshatmittal Akshat Mittal
1 parent 9c053da commit f17ac57
Showing 8 changed files with 394 additions and 323 deletions.
195 changes: 195 additions & 0 deletions contracts/NFT/VotingVanguardsRootstockCollective.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;

import {ERC721NonTransferrableUpgradable} from "../NFT/ERC721NonTransferrableUpgradable.sol";
import {GovernorRootstockCollective} from "../GovernorRootstockCollective.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract VotingVanguardsRootstockCollective is ERC721NonTransferrableUpgradable {
using Strings for uint256;

event IpfsFolderChanged(uint256 newNumFiles, string newIpfs);
event MintLimitChanged(uint256 newLimit);
event ProposalCountChanged(uint8 newCount);
event StRifThresholdChanged(uint256 newThreshold);

error HasNotVoted();
error MintLimitReached(uint256 mintLimit);
error OutOfTokens(uint256 maxSupply);
error BelowStRifThreshold(uint256 balance, uint256 requiredBalance);

// Rootstock Collective DAO Governor address
GovernorRootstockCollective public governor;
// Staked RIF token address
IERC20 public stRif;
// Counter for the total number of minted tokens
uint256 private _totalMinted;
// number of metadata files in the IPFS directory
uint256 private _maxSupply;
/**
* @notice Defines the maximum number of NFTs that can be claimed during the current phase.
* This allows the Foundation to unlock additional phases by updating the limit as needed
*/
uint256 public mintLimit;
// Minimum Staked RIF token balance to claim an NFT
uint256 public stRifThreshold;
// The number of proposals that need to be checked to determine whether the user voted for any of them
uint8 public proposalCount;
// IPFS CID of the tokens metadata directory
string private _folderIpfsCid;

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}

function initialize(
uint256 maxSupply,
uint256 initialMintLimit,
uint256 initialStRifThreshold,
address initialOwner,
address stRifAddress,
GovernorRootstockCollective governorAddress,
uint8 initialProposalCount,
string calldata ipfsFolderCid
) public initializer {
__ERC721UpgradableBase_init("VotingVanguardsRootstockCollective", "VV", initialOwner);
governor = governorAddress;
stRif = IERC20(stRifAddress);
setProposalCount(initialProposalCount);
setMintLimit(initialMintLimit);
setIpfsFolder(maxSupply, ipfsFolderCid);
setStRifThreshold(initialStRifThreshold);
}

/**
* @dev Sets a new IPFS folder and updates the maximum supply of tokens that can be minted.
* This function is meant to be called by an admin when the metadata folder on IPFS is updated.
* It ensures that the new maximum supply is greater than the previous one.
* @param newMaxSupply The new maximum number of tokens that can be minted.
* @param newIpfsCid The new IPFS CID for the metadata folder.
*/
function setIpfsFolder(uint256 newMaxSupply, string calldata newIpfsCid) public virtual onlyOwner {
require(newMaxSupply >= _maxSupply, "VotingVanguardsRootstockCollective: Invalid max supply");
_maxSupply = newMaxSupply;
_folderIpfsCid = newIpfsCid;
emit IpfsFolderChanged(newMaxSupply, newIpfsCid);
}

/**
* @dev Checks whether the specified address (`caller`) has voted on any of the last
* `numProposals` proposals. Iterates through the most recent proposals to determine
* if a vote exists for the caller.
*
* @param caller The address to check for voting activity.
* @return True if the caller has voted on at least one of the checked proposals, otherwise false.
*/
function hasVoted(address caller) public view virtual returns (bool) {
uint256 firstCheckIndex = governor.proposalCount();
uint256 lastCheckIndex = firstCheckIndex > proposalCount ? firstCheckIndex - proposalCount : 0;
for (uint256 i = firstCheckIndex; i > lastCheckIndex; ) {
// slither-disable-next-line unused-return
(uint256 proposalId, , , , ) = governor.proposalDetailsAt(i - 1);
if (governor.hasVoted(proposalId, caller)) return true;
// Disable overflow check to save gas, as `i` is guaranteed to be > 0 in this loop
unchecked {
i--;
}
}
return false;
}

/**
* @dev Updates the `proposalCount`, which determines the number of recent proposals
* to check for user voting activity. Allows the owner to adjust the depth of the check.
*/
function setProposalCount(uint8 newCount) public virtual onlyOwner {
emit ProposalCountChanged(newCount);
proposalCount = newCount;
}

/**
* @dev Updates the `mintLimit` to define the maximum number of NFTs claimable in the current phase
*/
function setMintLimit(uint256 newMintLimit) public virtual onlyOwner {
emit MintLimitChanged(newMintLimit);
mintLimit = newMintLimit;
}

/**
* @dev Sets a new minimum StRIF balance to claim the NFT.
*/
function setStRifThreshold(uint256 newThreshold) public virtual onlyOwner {
emit StRifThresholdChanged(newThreshold);
stRifThreshold = newThreshold;
}

function mint() external virtual {
address caller = _msgSender();
// make sure the minter's stRIF balance is above the minimum threshold
uint256 stRifBalance = stRif.balanceOf(caller);
if (stRifBalance < stRifThreshold) revert BelowStRifThreshold(stRifBalance, stRifThreshold);
// make sure we still have some CIDs for minting new tokens
if (tokensAvailable() == 0) revert OutOfTokens(_maxSupply);
// revert if minter hasn't voted in the last `proposalCount` proposals
if (!hasVoted(caller)) revert HasNotVoted();
uint256 tokenId = ++_totalMinted;
// revert if the mint limit in the current minting phase was reached.
if (tokenId > mintLimit) revert MintLimitReached(mintLimit);
string memory fileName = string.concat(tokenId.toString(), ".json"); // 1.json, 2.json ...
_safeMint(caller, tokenId);
_setTokenURI(tokenId, fileName);
}

/**
* @dev Returns the number of tokens available for minting
*/
function tokensAvailable() public view virtual returns (uint256) {
if (_totalMinted >= _maxSupply) return 0;
return _maxSupply - _totalMinted;
}

/**
* @dev Returns the token ID for a given owner address.
* This is a simplified version of the `tokenOfOwnerByIndex` function without the index
* parameter, since a community member can only own one token.
*/
function tokenIdByOwner(address owner) public view virtual returns (uint256) {
return tokenOfOwnerByIndex(owner, 0);
}

/**
* @dev Returns the token IPFS URI for the given owner address.
* This utility function combines two view functions.
*/
function tokenUriByOwner(address owner) public view virtual returns (string memory) {
return tokenURI(tokenIdByOwner(owner));
}

/**
* @dev Returns the base URI used for constructing the token URI.
* @return The base URI string.
*/
function _baseURI() internal view virtual override returns (string memory) {
return string.concat("ipfs://", _folderIpfsCid, "/");
}

/**
* @dev Prevents the transfer and mint of tokens to addresses that already own one.
* Ensures that one address cannot own more than one token.
*/
function _update(address to, uint256 tokenId, address auth) internal override returns (address) {
// Disallow transfers by smart contracts, as only EOAs can be community members
// slither-disable-next-line tx-origin
if (_msgSender() != tx.origin) revert ERC721InvalidOwner(_msgSender());
// disallow transfers to members (excluding zero-address for enabling burning)
// disable minting more than one token
if (to != address(0) && balanceOf(to) > 0) revert ERC721InvalidOwner(to);
return super._update(to, tokenId, auth);
}

function _authorizeUpgrade(address newImplementation) internal virtual override onlyOwner {}
}
122 changes: 0 additions & 122 deletions contracts/test/VanguardNFT.sol

This file was deleted.

2 changes: 1 addition & 1 deletion hardhat.config.ts
Original file line number Diff line number Diff line change
@@ -27,7 +27,7 @@ const config: HardhatUserConfig = {
],
},
gasReporter: {
enabled: true,
enabled: false,
reportPureAndViewMethods: true,
showUncalledMethods: false,
},
33 changes: 0 additions & 33 deletions ignition/modules/VanguardNFTModule.ts

This file was deleted.

36 changes: 36 additions & 0 deletions ignition/modules/VotingVanguardsModule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { buildModule } from '@nomicfoundation/hardhat-ignition/modules'

export const VanguardNFTModule = buildModule('VanguardNFT', m => {
// deploy implementation
const implementation = m.contract('VotingVanguardsRootstockCollective', [], { id: 'Implementation' })

// initializer parameters
const maxSupply = m.getParameter<number>('maxSupply')
const mintLimit = m.getParameter<number>('mintLimit')
const stRifThreshold = m.getParameter<bigint>('stRifThreshold')
const initialOwner = m.getAccount(0)
const stRif = m.getParameter<string>('stRif')
const governor = m.getParameter<string>('governor')
const proposalCount = m.getParameter<number>('proposalCount')
const ipfsFolderCid = m.getParameter<string>('ipfsFolderCid')

// deploy proxy
const proxy = m.contract('ERC1967Proxy', [
implementation,
m.encodeFunctionCall(
implementation,
'initialize',
[maxSupply, mintLimit, stRifThreshold, initialOwner, stRif, governor, proposalCount, ipfsFolderCid],
{
id: 'Proxy',
},
),
])
const VanguardNFT = m.contractAt('VotingVanguardsRootstockCollective', proxy, {
id: 'Contract',
})

return { VanguardNFT }
})

export default VanguardNFTModule
6 changes: 5 additions & 1 deletion params/VanguardNFT/testnet.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"VanguardNFT": {
"maxSupply": 1000,
"mintLimit": 200,
"stRifThreshold": 50000000000000000000,
"stRif": "0x4861198e9A6814EBfb152552D1b1a37426C54D23",
"governor": "0x2109FF4a9D5548a21F877cA937Ac5847Fde49694",
"maxSupply": 20,
"proposalCount": 3,
"ipfsFolderCid": "QmPaCP36tFjXp7xqcPi4ggatL7w4dsWKGTv1kpaSVkv9KW"
}
}
227 changes: 157 additions & 70 deletions test/Vanguard.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { ethers, ignition } from 'hardhat'
import { GovernorRootstockCollective, VanguardNFTRootstockCollective } from '../typechain-types'
import VanguardNFTModule from '../ignition/modules/VanguardNFTModule'
import {
GovernorRootstockCollective,
RIFToken,
StRIFToken,
VotingVanguardsRootstockCollective,
} from '../typechain-types'
import VotingVanguardsModule from '../ignition/modules/VotingVanguardsModule'
import { deployContracts } from './deployContracts'
import { expect } from 'chai'
import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'
@@ -9,10 +14,16 @@ import { loadFixture, mine } from '@nomicfoundation/hardhat-network-helpers'
import { VoteType } from '../types'

const ZERO_BYTE = '0x00'
const rootstockGasPriceAverage = 63228564n

describe('Vanguard NFT', () => {
const proposalCount = 10
// The number of proposals that need to be checked to determine whether the user voted for any of them
const proposalCount = 3
const ipfsFolderCid = 'QmZYHgFMjZ9SNvFwP9rCxtDEgF2JfJwQtaSg2fqxTX31eG'
const stRifThreshold = 50n * 10n ** 18n
const votingPower = 100n * 10n ** 18n
const maxSupply = 1000
const mintLimit = 200

let deployer: SignerWithAddress
let voter: SignerWithAddress
@@ -22,25 +33,41 @@ describe('Vanguard NFT', () => {
;[deployer, voter, proposalTarget] = await ethers.getSigners()
})

// The number of proposals that need to be checked to determine whether the user voted for any of them
// give voting power
const enfranchise = async (rif: RIFToken, stRIF: StRIFToken) => {
// give voting power to deployer to be able to create a proposal
await rif.approve(await stRIF.getAddress(), votingPower).then(tx => tx.wait())
await stRIF.depositAndDelegate(deployer.address, votingPower).then(tx => tx.wait())
// give voting power to voter
await rif.transfer(voter.address, votingPower).then(tx => tx.wait())
await rif
.connect(voter)
.approve(await stRIF.getAddress(), votingPower)
.then(tx => tx.wait())
await stRIF
.connect(voter)
.depositAndDelegate(voter.address, votingPower)
.then(tx => tx.wait())
}

const deploy = async () => {
const contracts = await deployContracts()
const vanguard = (
await ignition.deploy(VanguardNFTModule, {
await ignition.deploy(VotingVanguardsModule, {
parameters: {
VanguardNFT: {
maxSupply,
mintLimit,
stRifThreshold,
stRif: await contracts.stRIF.getAddress(),
governor: await contracts.governor.getAddress(),
maxSupply: 10n,
proposalCount,
ipfsFolderCid,
},
},
})
).VanguardNFT as unknown as VanguardNFTRootstockCollective
const votingPower = 100n * 10n ** 18n
await contracts.rif.approve(await contracts.stRIF.getAddress(), votingPower).then(tx => tx.wait())
await contracts.stRIF.depositAndDelegate(deployer.address, votingPower).then(tx => tx.wait())
).VanguardNFT as unknown as VotingVanguardsRootstockCollective
await enfranchise(contracts.rif, contracts.stRIF)
return { ...contracts, vanguard }
}

@@ -53,73 +80,133 @@ describe('Vanguard NFT', () => {
return event.args.proposalId
}

const testMintGas = async (numAdditionalProposals: number) => {
const { governor, vanguard } = await loadFixture(deploy)
describe('NFT lifecycle', () => {
let governor: GovernorRootstockCollective
let vanguard: VotingVanguardsRootstockCollective
let stRif: StRIFToken

// Create and vote for the initial proposal
const id = await createProposal(governor)
await governor
.connect(voter)
.castVote(id, VoteType.For)
.then(tx => tx.wait())

// Create additional proposals
for (let i = 0; i < numAdditionalProposals; i++) {
await createProposal(governor)
}
before(async () => {
const contracts = await loadFixture(deploy)
governor = contracts.governor
vanguard = contracts.vanguard
stRif = contracts.stRIF
})

// Mint and measure gas
const mintTx = await vanguard.connect(voter).mint()
const mintReceipt = await mintTx.wait()
if (!mintReceipt) throw new Error('Unable to mint')
const { gasPrice, gasUsed } = mintReceipt
return {
gasPrice,
gasUsed,
gasFee: +formatEther(gasPrice * gasUsed),
}
}
describe('Upon deployment', async () => {
it('Contract names should be set after the deployment', async () => {
expect(await governor.name()).to.equal('GovernorRootstockCollective')
expect(await vanguard.name()).to.equal('VotingVanguardsRootstockCollective')
})
it('number of past votes to be checked should be set up', async () => {
expect(await vanguard.proposalCount()).to.equal(proposalCount)
})
it('StRif threshold should be set', async () => {
expect(await vanguard.stRifThreshold()).to.equal(stRifThreshold)
})
it('StRif address should be set in the Vanguard contract', async () => {
expect(await vanguard.stRif()).to.equal(await stRif.getAddress())
})
it('Mint limit should be set', async () => {
expect(await vanguard.mintLimit()).to.equal(mintLimit)
})
it('All tokens should be available for minting', async () => {
expect(await vanguard.tokensAvailable()).to.equal(maxSupply)
})
it('Number of proposal to search should be set', async () => {
expect(await vanguard.proposalCount()).to.equal(proposalCount)
})

describe('measuring gas', () => {
it('mint after 0 - 10 proposals', async () => {
let totalGasPrice = 0n
for (let i = 0; i < proposalCount; i++) {
const { gasPrice } = await testMintGas(i)
totalGasPrice += gasPrice
}
console.log('average gas price', totalGasPrice / BigInt(proposalCount))
it('Voter`s StRif balance should be above the StRif threshold', async () => {
expect(await stRif.balanceOf(voter.address)).to.be.greaterThanOrEqual(stRifThreshold)
})
it('Voter should have enough voting power to vote', async () => {
expect(await stRif.getVotes(voter.address)).to.equal(votingPower)
})
})
})
})

/* describe.skip('Upon deployment', () => {
it('Contract names should be set after the deployment', async () => {
expect(await governor.name()).to.equal('GovernorRootstockCollective')
expect(await vanguard.name()).to.equal('VanguardNFTRootstockCollective')
})
it('number of past votes to be checked should be set up', async () => {
expect(await vanguard.proposalCount()).to.equal(proposalCount)
describe('Minting NFTs', () => {
it('Voter should not be able to mint NFT before voting', async () => {
await expect(vanguard.connect(voter).mint()).to.be.revertedWithCustomError(vanguard, 'HasNotVoted')
})
it('hasVoted should detect that voter hasn`t voted yet', async () => {
expect(await vanguard.hasVoted(voter.address)).to.be.false
})
it('Governor should now store 0 proposals', async () => {
expect(await governor.proposalCount()).to.equal(0)
})
it('Voter should NOT be able to mint an NFT if he voted long ago (before `proposalCount`)', async () => {
const id = await createProposal(governor)

await governor.connect(voter).castVote(id, VoteType.For)
// voter misses 3 proposals
await createProposal(governor)
await createProposal(governor)
await createProposal(governor)
expect(await vanguard.hasVoted(voter.address)).to.be.false
})
it('voter should vote for a proposal', async () => {
const id = await createProposal(governor)
await expect(governor.connect(voter).castVote(id, VoteType.For)).to.emit(governor, 'VoteCast')
expect(await governor.hasVoted(id, voter.address)).to.be.true
})
it('should create 2 more proposals', async () => {
for (let i = 0; i < 2; i++) {
await createProposal(governor)
}
})
it('hasVoted should detect that voter has already voted', async () => {
expect(await vanguard.hasVoted(voter.address)).to.be.true
})
it('Voter should be able to mint NFT after voting', async () => {
await expect(vanguard.connect(voter).mint()).to.emit(vanguard, 'Transfer')
})
it('Voter should NOT be able to mint NFT second time', async () => {
await expect(vanguard.connect(voter).mint()).to.be.revertedWithCustomError(
vanguard,
'ERC721InvalidOwner',
)
})
})
})
describe.skip('Minting NFTs', () => {
it('Voter should not be able to mint NFT before voting', async () => {
await expect(vanguard.connect(voter).mint()).to.be.revertedWithCustomError(vanguard, 'HasNotVoted')
})
it.skip('voter should vote for a proposal', async () => {
const id = await createProposal()
proposalIds.push(id)
await expect(governor.connect(voter).castVote(id, VoteType.For)).to.emit(governor, 'VoteCast')
expect(await governor.hasVoted(id, voter.address)).to.be.true
})
it('should create 10 proposals', async () => {

describe('Measuring gas', () => {
const testMintGas = async (numAdditionalProposals: number) => {
const { governor, vanguard } = await loadFixture(deploy)

// Create and vote for the initial proposal
const id = await createProposal(governor)
await governor
.connect(voter)
.castVote(id, VoteType.For)
.then(tx => tx.wait())

// Create additional proposals
for (let i = 0; i < numAdditionalProposals; i++) {
await createProposal(governor)
}

// Mint and measure gas
const mintTx = await vanguard.connect(voter).mint()
const mintReceipt = await mintTx.wait()
if (!mintReceipt) throw new Error('Unable to mint')
const { gasPrice, gasUsed } = mintReceipt
return {
gasPrice,
gasUsed,
gasFee: +formatEther(rootstockGasPriceAverage * gasUsed),
}
}

it('Fees paid for a single proposal check should be reasonable', async () => {
const gasFees: number[] = []
for (let i = 0; i < proposalCount; i++) {
await createProposal()
const { gasFee } = await testMintGas(i)
gasFees.push(gasFee)
}
const feesPerProposalCheck = gasFees.slice(1).map((current, index) => current - gasFees[index])
const averageFeePerProposalCheck =
feesPerProposalCheck.reduce((sum, diff) => sum + diff, 0) / feesPerProposalCheck.length
expect(averageFeePerProposalCheck).lessThan(0.000002)
})
it('hasVoted should detect that voter hasn`t voted yet', async () => {
expect(await vanguard.hasVoted(voter.address, proposalCount)).to.be.false
})
it('Voter should be able to mint NFT after voting', async () => {
await expect(vanguard.connect(voter).mint()).to.emit(vanguard, 'Transfer')
})
*/
})
})
96 changes: 0 additions & 96 deletions test/gasCheckForNFT.test.ts

This file was deleted.

0 comments on commit f17ac57

Please sign in to comment.