diff --git a/contracts/NFT/ERC721UpgradableAirdroppable.sol b/contracts/NFT/ERC721UpgradableAirdroppable.sol index bdd8d33..7e2719e 100644 --- a/contracts/NFT/ERC721UpgradableAirdroppable.sol +++ b/contracts/NFT/ERC721UpgradableAirdroppable.sol @@ -2,47 +2,48 @@ // Compatible with OpenZeppelin Contracts ^5.0.0 pragma solidity ^0.8.20; -import {ERC721UpgradableNonTransferrable} from "./ERC721UpgradableNonTransferrable.sol"; +import {ERC721UpgradableBase} from "./ERC721UpgradableBase.sol"; -abstract contract ERC721UpgradableAirdroppable is ERC721UpgradableNonTransferrable { - struct AirdroppableStorage { - uint256 _nextTokenId; - uint256 _maxNftSupply; - } +struct AirdropRecipient { + address receiver; + string ipfsCid; +} +struct AirdroppableStorage { + uint256 _nextTokenId; +} + +interface IAirdroppable { + event AirdropExecuted(uint256 numMinted); + + function airdrop(AirdropRecipient[] calldata receivers) external; +} + +abstract contract ERC721UpgradableAirdroppable is ERC721UpgradableBase, IAirdroppable { // keccak256(abi.encode(uint256(keccak256("rootstock.storage.ERC721Airdroppable")) - 1)) & ~bytes32(uint256(0xff)) - bytes32 private constant storageLocation = + bytes32 private constant STORAGE_LOCATION = 0xb0b7c350b577073edef3635128030d229b37650766e59f19e264b4b50f30a500; function _getStorage() private pure returns (AirdroppableStorage storage $) { assembly { - $.slot := storageLocation + $.slot := STORAGE_LOCATION } } - function __ERC721Airdroppable_init(uint256 maxNftSupply) internal onlyInitializing { - AirdroppableStorage storage $ = _getStorage(); - $._maxNftSupply = maxNftSupply; - } - /** * @dev Distributes tokens to a list of addresses. * - * @param ipfsCids An array of content identifiers (IPFS CIDs) for the token URIs. - * @param airdropAddresses An array of addresses to receive the tokens. + * @param receivers an array of receivers with corresponding IPFS CIDS */ - - function airdrop(string[] calldata ipfsCids, address[] calldata airdropAddresses) public onlyOwner { + function airdrop(AirdropRecipient[] calldata receivers) external virtual override onlyOwner { AirdroppableStorage storage $ = _getStorage(); - require(ipfsCids.length == airdropAddresses.length, "Arrays must be of the same length"); - require(airdropAddresses.length <= $._maxNftSupply, "Too many airdrops at once"); uint256 tokenId = $._nextTokenId; - unchecked { - for (uint256 i = 0; i < airdropAddresses.length; i++) { - tokenId++; - _safeMint(airdropAddresses[i], tokenId); - _setTokenURI(tokenId, ipfsCids[i]); - } + for (uint256 i = 0; i < receivers.length; i++) { + tokenId++; + AirdropRecipient calldata item = receivers[i]; + _safeMint(item.receiver, tokenId); + _setTokenURI(tokenId, item.ipfsCid); } $._nextTokenId = tokenId; + emit AirdropExecuted(receivers.length); } } diff --git a/contracts/NFT/ERC721UpgradableBase.sol b/contracts/NFT/ERC721UpgradableBase.sol index e807913..158b5d3 100644 --- a/contracts/NFT/ERC721UpgradableBase.sol +++ b/contracts/NFT/ERC721UpgradableBase.sol @@ -19,8 +19,8 @@ abstract contract ERC721UpgradableBase is UUPSUpgradeable { function __ERC721UpgradableBase_init( - string calldata name, - string calldata symbol, + string memory name, + string memory symbol, address initialOwner ) internal onlyInitializing { __ERC721_init(name, symbol); @@ -35,7 +35,7 @@ abstract contract ERC721UpgradableBase is function _increaseBalance( address account, uint128 value - ) internal override(ERC721Upgradeable, ERC721EnumerableUpgradeable) { + ) internal virtual override(ERC721Upgradeable, ERC721EnumerableUpgradeable) { super._increaseBalance(account, value); } @@ -43,7 +43,7 @@ abstract contract ERC721UpgradableBase is address to, uint256 tokenId, address auth - ) internal override(ERC721Upgradeable, ERC721EnumerableUpgradeable) returns (address) { + ) internal virtual override(ERC721Upgradeable, ERC721EnumerableUpgradeable) returns (address) { return super._update(to, tokenId, auth); } @@ -52,6 +52,7 @@ abstract contract ERC721UpgradableBase is ) public view + virtual override(ERC721Upgradeable, ERC721EnumerableUpgradeable, ERC721URIStorageUpgradeable) returns (bool) { @@ -60,7 +61,7 @@ abstract contract ERC721UpgradableBase is function tokenURI( uint256 tokenId - ) public view override(ERC721Upgradeable, ERC721URIStorageUpgradeable) returns (string memory) { + ) public view virtual override(ERC721Upgradeable, ERC721URIStorageUpgradeable) returns (string memory) { return super.tokenURI(tokenId); } } diff --git a/contracts/NFT/OGFounders.sol b/contracts/NFT/OGFounders.sol new file mode 100644 index 0000000..f816d4c --- /dev/null +++ b/contracts/NFT/OGFounders.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts ^5.0.0 +pragma solidity ^0.8.20; + +import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +import {ERC721UpgradableNonTransferrable} from "./ERC721UpgradableNonTransferrable.sol"; + +contract OGFounders is ERC721UpgradableNonTransferrable { + using Strings for uint8; + + error WasNotEnoughStRIFToMint(uint stRIF); + error CouldNotGetVotes(string); + error CouldNotGetVotesBytes(bytes); + error OutOfTokens(uint256 maxSupply); + error ThisAddressAlreadyOwnsTheToken(address owner); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + address public stRIF; + uint256 public firstProposalDate; + // Counter for the total number of minted tokens + uint8 private _totalMinted; + // number of metadata files in the IPFS directory + uint8 private _maxSupply; + + function initialize( + address initialOwner, + address stRIFAddress, + uint256 _firstProposalDate + ) public initializer { + __ERC721UpgradableBase_init("OGFoundersRootstockCollective", "OGF", initialOwner); + stRIF = stRIFAddress; + firstProposalDate = _firstProposalDate; + _maxSupply = 150; + } + + /** + * @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)); + } + + function mint() external virtual { + address caller = _msgSender(); + //5623028 + try IVotes(stRIF).getPastVotes(caller, firstProposalDate) returns (uint _votes) { + if (_votes < 1) { + revert WasNotEnoughStRIFToMint(_votes); + } + // make sure we still have some CIDs for minting new tokens + if (tokensAvailable() == 0) revert OutOfTokens(_maxSupply); + + // minting + uint8 tokenId = ++_totalMinted; + string memory fileName = string.concat(tokenId.toString(), ".json"); // 1.json, 2.json ... + _safeMint(caller, tokenId); + _setTokenURI(tokenId, fileName); + } catch Error(string memory reason) { + revert CouldNotGetVotes(reason); + } catch (bytes memory reason) { + revert CouldNotGetVotesBytes(reason); + } + } + + function _authorizeUpgrade(address newImplementation) internal virtual override onlyOwner {} + + function _baseURI() internal pure override returns (string memory) { + return "ipfs://"; + } + + /** + * @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); + } + + // overrides required + + function transferFrom(address from, address to, uint256 tokenId) public virtual override { + super.transferFrom(from, to, tokenId); + } + + function approve(address to, uint256 tokenId) public virtual override { + super.approve(to, tokenId); + } + + function setApprovalForAll(address operator, bool approved) public virtual override { + super.setApprovalForAll(operator, approved); + } + + function tokenURI(uint256 tokenId) public view virtual override returns (string memory) { + return super.tokenURI(tokenId); + } + + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return super.supportsInterface(interfaceId); + } + + function _increaseBalance(address account, uint128 value) internal override { + super._increaseBalance(account, value); + } +} diff --git a/contracts/NFT/OgFoundersEcosystemPartner.sol b/contracts/NFT/OgFoundersEcosystemPartner.sol index 17f9c53..78df847 100644 --- a/contracts/NFT/OgFoundersEcosystemPartner.sol +++ b/contracts/NFT/OgFoundersEcosystemPartner.sol @@ -2,22 +2,19 @@ // Compatible with OpenZeppelin Contracts ^5.0.0 pragma solidity ^0.8.20; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import {ERC721Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; import {ERC721UpgradableAirdroppable} from "./ERC721UpgradableAirdroppable.sol"; +import {ERC721UpgradableNonTransferrable} from "./ERC721UpgradableNonTransferrable.sol"; -contract OgFoundersEcosystemPartner is ERC721UpgradableAirdroppable { +contract OgFoundersEcosystemPartner is ERC721UpgradableAirdroppable, ERC721UpgradableNonTransferrable { /// @custom:oz-upgrades-unsafe-allow constructor constructor() { _disableInitializers(); } - function initialize( - string calldata contractName, - string calldata symbol, - address initialOwner, - uint256 maxNftSupply - ) public initializer { - __ERC721Airdroppable_init(maxNftSupply); - __ERC721UpgradableBase_init(contractName, symbol, initialOwner); + function initialize(address initialOwner) public initializer { + __ERC721UpgradableBase_init("OgFoundersEcosystemPartner", "OGFEP", initialOwner); } function _authorizeUpgrade(address newImplementation) internal virtual override onlyOwner {} @@ -25,4 +22,28 @@ contract OgFoundersEcosystemPartner is ERC721UpgradableAirdroppable { function _baseURI() internal pure override returns (string memory) { return "ipfs://"; } + + /* Overrides required by Solidity */ + + function approve( + address to, + uint256 tokenId + ) public virtual override(IERC721, ERC721Upgradeable, ERC721UpgradableNonTransferrable) { + super.approve(to, tokenId); + } + + function setApprovalForAll( + address operator, + bool approved + ) public virtual override(IERC721, ERC721Upgradeable, ERC721UpgradableNonTransferrable) { + super.setApprovalForAll(operator, approved); + } + + function transferFrom( + address from, + address to, + uint256 tokenId + ) public virtual override(IERC721, ERC721Upgradeable, ERC721UpgradableNonTransferrable) { + super.transferFrom(from, to, tokenId); + } } diff --git a/hardhat.config.ts b/hardhat.config.ts index 0165c45..6406e22 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -4,6 +4,7 @@ import dotent from 'dotenv' import './tasks/updateIpfsFolder' import './tasks/cancelProposal' import './tasks/withdrawTreasury' +import './tasks/airdrop' dotent.config() diff --git a/ignition/modules/OGFounders.ts b/ignition/modules/OGFounders.ts new file mode 100644 index 0000000..6813ac2 --- /dev/null +++ b/ignition/modules/OGFounders.ts @@ -0,0 +1,26 @@ +import { buildModule } from '@nomicfoundation/hardhat-ignition/modules' + +export const OGFoundersProxyModule = buildModule('OGFounders', m => { + // deploy implementation + const implementation = m.contract('OGFounders', [], { id: 'Implementation' }) + + // initializer parameters + const deployer = m.getAccount(0) + const stRIFAddress = m.getParameter('stRIFAddress') + const firstProposalDate = m.getParameter('firstProposalDate') + + // deploy proxy + const proxy = m.contract('ERC1967Proxy', [ + implementation, + m.encodeFunctionCall(implementation, 'initialize', [deployer, stRIFAddress, firstProposalDate], { + id: 'Proxy', + }), + ]) + const OGFounders = m.contractAt('OGFounders', proxy, { + id: 'Contract', + }) + + return { OGFounders } +}) + +export default OGFoundersProxyModule diff --git a/ignition/modules/OgFoundersEP.ts b/ignition/modules/OgFoundersEP.ts index efd8889..45b83db 100644 --- a/ignition/modules/OgFoundersEP.ts +++ b/ignition/modules/OgFoundersEP.ts @@ -5,30 +5,19 @@ export const ogFoundersEpProxyModule = buildModule('OgFoundersEP', m => { const implementation = m.contract('OgFoundersEcosystemPartner', [], { id: 'Implementation' }) // initializer parameters - const contractName = m.getParameter('contractName') - const contractSymbol = m.getParameter('symbol') const deployer = m.getAccount(0) - const maxNftSupply = m.getParameter('maxNftSupply') // deploy proxy const proxy = m.contract('ERC1967Proxy', [ implementation, - m.encodeFunctionCall( - implementation, - 'initialize', - [contractName, contractSymbol, deployer, maxNftSupply], - { id: 'Proxy' }, - ), + m.encodeFunctionCall(implementation, 'initialize', [deployer], { + id: 'Proxy', + }), ]) const ogFoundersEp = m.contractAt('OgFoundersEcosystemPartner', proxy, { id: 'Contract', }) - // Airdrop - const ipfsCids = m.getParameter('ipfsCids') - const airdropAddresses = m.getParameter('airdropAddresses') - m.call(ogFoundersEp, 'airdrop', [ipfsCids, airdropAddresses]) - return { ogFoundersEp } }) diff --git a/params/OgFoundersEP/airdrop-testnet.json b/params/OgFoundersEP/airdrop-testnet.json new file mode 100644 index 0000000..5c605eb --- /dev/null +++ b/params/OgFoundersEP/airdrop-testnet.json @@ -0,0 +1,22 @@ +[ + { + "receiver": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "ipfsCid": "QmeGNJxhSToWYvHucUMX4D2fX8Uk2aL8CujwFuGw2Moy9U" + }, + { + "receiver": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "ipfsCid": "QmPFcgjgqCMoEbBmkbnvwaknENTH3WckjCGYekQhXJFVzn" + }, + { + "receiver": "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + "ipfsCid": "Qme6NCykZUUv78jXydTgmmehHThVXpJHFxAbZvEJkkPk5o" + }, + { + "receiver": "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", + "ipfsCid": "QmUrqydS9kLYDJtv6dY7suhT5m5UPxJe1K1yq9b7QoY1VD" + }, + { + "receiver": "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + "ipfsCid": "QmcbYfu8CuhHRfRmDHwc8P8vqpgnYSCuzLcwtr5n4afMKb" + } +] \ No newline at end of file diff --git a/params/OgFoundersEP/mainnet.json b/params/OgFoundersEP/mainnet.json deleted file mode 100644 index 2cbbe71..0000000 --- a/params/OgFoundersEP/mainnet.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "OgFoundersEP": { - "ipfsCids": [], - "airdropAddresses": [] - } -} \ No newline at end of file diff --git a/params/OgFoundersEP/testnet.json b/params/OgFoundersEP/testnet.json deleted file mode 100644 index 6caef2b..0000000 --- a/params/OgFoundersEP/testnet.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "OgFoundersEP": { - "contractName": "OgFoundersEcosystemPartner", - "symbol": "OGFEP", - "maxNftSupply": 5, - "ipfsCids": [ - "QmeGNJxhSToWYvHucUMX4D2fX8Uk2aL8CujwFuGw2Moy9U", - "QmPFcgjgqCMoEbBmkbnvwaknENTH3WckjCGYekQhXJFVzn", - "Qme6NCykZUUv78jXydTgmmehHThVXpJHFxAbZvEJkkPk5o", - "QmUrqydS9kLYDJtv6dY7suhT5m5UPxJe1K1yq9b7QoY1VD", - "QmcbYfu8CuhHRfRmDHwc8P8vqpgnYSCuzLcwtr5n4afMKb" - ], - "airdropAddresses": [ - "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - "0x90F79bf6EB2c4f870365E785982E1f101E93b906", - "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", - "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" - ] - } -} \ No newline at end of file diff --git a/tasks/airdrop.ts b/tasks/airdrop.ts new file mode 100644 index 0000000..36c733c --- /dev/null +++ b/tasks/airdrop.ts @@ -0,0 +1,41 @@ +import { task } from 'hardhat/config' +import { HardhatRuntimeEnvironment } from 'hardhat/types' +import fs from 'fs-extra' + +interface AirdropRecipient { + receiver: string + ipfsCid: string +} + +/** + * Executes an airdrop of NFTs to a list of recipients. + * + * @param hre - The Hardhat runtime environment. + * @param nftAddress - The address of the NFT smart contract. It can be any contract + * that implements `IAirdroppable` interface + * @param receivers - An array of objects containing the receiver's address and the corresponding IPFS CID. + */ +async function airdrop(hre: HardhatRuntimeEnvironment, nftAddress: string, receivers: AirdropRecipient[]) { + // + const contract = await hre.ethers.getContractAt('IAirdroppable', nftAddress) + const tx = await contract.airdrop(receivers) + await tx.wait() +} + +interface Parameters { + nft: string + receivers: string +} + +task('airdrop', 'Execute NFT airdrop') + .addParam('nft', 'NFT smart contract address') + .addParam('receivers', 'JSON file with a list of token receiver addresses and corresponding IPFS CIDs') + .setAction(async ({ nft, receivers }: Parameters, hre) => { + try { + const airdropReceivers: AirdropRecipient[] = await fs.readJson(receivers) + airdrop(hre, nft, airdropReceivers) + console.log('Airdrop was executed') + } catch (error) { + console.log(error instanceof Error ? error.message : error) + } + }) diff --git a/test/Governor.test.ts b/test/Governor.test.ts index 47ce802..bb50778 100644 --- a/test/Governor.test.ts +++ b/test/Governor.test.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { ethers } from 'hardhat' +import { ethers, ignition } from 'hardhat' import { loadFixture, mine, time } from '@nomicfoundation/hardhat-toolbox/network-helpers' import { RIFToken, @@ -7,11 +7,13 @@ import { StRIFToken, DaoTimelockUpgradableRootstockCollective, ProposalTarget, + OGFounders, } from '../typechain-types' import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' import { ContractTransactionResponse, parseEther, solidityPackedKeccak256 } from 'ethers' import { Proposal, ProposalState, OperationState } from '../types' import { deployContracts } from './deployContracts' +import ogFoundersProxyModule from '../ignition/modules/OGFounders' describe('Governor Contact', () => { const initialVotingDelay = 1n @@ -142,7 +144,7 @@ describe('Governor Contact', () => { describe('Proposal Creation', () => { it('participants should gain voting power proportional to RIF tokens', async () => { await Promise.all( - holders.map(async (voter, i) => { + holders.slice(0, holders.length - 1).map(async (voter, i) => { const dispenseTx = await rif.transfer(voter.address, dispenseValue) await dispenseTx.wait() const rifBalance = await rif.balanceOf(voter.address) @@ -229,6 +231,94 @@ describe('Governor Contact', () => { const remainingVotes = quorum - totalVotes expect(remainingVotes).equal(quorum) }) + + describe('OG Founders NFT', () => { + let ogFoundersNFT: OGFounders + let tokensLeft = 150 + + before(async () => { + const contract = await ignition.deploy(ogFoundersProxyModule, { + parameters: { + OGFounders: { + stRIFAddress: await stRIF.getAddress(), + firstProposalDate: proposalSnapshot, + }, + }, + }) + ogFoundersNFT = contract.OGFounders as unknown as OGFounders + }) + + it('the OG Founders NFT should be deployed', async () => { + expect(await ogFoundersNFT.getAddress()).to.be.properAddress + }) + + it('should set up proper NFT name, symbol', async () => { + expect(await ogFoundersNFT.connect(deployer).name()).to.equal('OGFoundersRootstockCollective') + expect(await ogFoundersNFT.symbol()).to.equal('OGF') + }) + + it('should have a correct stRIF address', async () => { + expect(await stRIF.getAddress()).to.equal(await ogFoundersNFT.stRIF()) + }) + + it('should have a tokensAvailable at 150 in the beginning', async () => { + expect(await ogFoundersNFT.tokensAvailable()).to.equal(150) + }) + + it('holders who gained votes before 1st proposal should be able to mint the NFT', async () => { + await Promise.all( + holders.slice(0, holders.length - 1).map(async h => { + await ogFoundersNFT.connect(h).mint() + expect(await ogFoundersNFT.balanceOf(h.address)).to.equal(1) + const tokenId = await ogFoundersNFT.tokenIdByOwner(h.address) + expect(await ogFoundersNFT.ownerOf(tokenId)).to.equal(h.address) + tokensLeft-- + }), + ) + }) + + it('tokenAvailable should now return 150 - tokensLeft', async () => { + expect(await ogFoundersNFT.tokensAvailable()).to.equal(tokensLeft) + }) + + it('should have a holder who have not owned enough stRIF at the time of proposalSnapshot', async () => { + const lastHoldersBalance = await stRIF.getPastVotes( + await holders[holders.length - 1].getAddress(), + proposalSnapshot, + ) + + expect(lastHoldersBalance).to.equal(0) + }) + + it('should NOT be possible to claim NFT if you have not owned at least 1 stRIF before 1st proposal', async () => { + const tx = ogFoundersNFT.connect(holders[holders.length - 1]).mint() + expect(tx).to.be.revertedWithCustomError( + { interface: ogFoundersNFT.interface }, + 'WasNotEnoughStRIFToMint', + ) + }) + + it('should NOT be possible to claim more than once', async () => { + await Promise.all( + holders.slice(0, 1).map(async h => { + const tx = ogFoundersNFT.connect(h).mint() + expect(tx).to.be.revertedWithCustomError( + { interface: ogFoundersNFT.interface }, + 'ERC721InvalidOwner', + ) + }), + ) + }) + + it('transferFrom should be forbidden', async () => { + const tokenIdOwned = await ogFoundersNFT.tokenIdByOwner(holders[0]) + const tx = ogFoundersNFT.transferFrom(holders[0], holders[1], tokenIdOwned) + expect(tx).to.be.revertedWithCustomError( + { interface: ogFoundersNFT.interface }, + 'TransfersDisabled', + ) + }) + }) }) describe('Voting', () => { @@ -448,6 +538,16 @@ describe('Governor Contact', () => { expect(deployer.address).to.equal(guardianAddress) }) + it('proposalProposer should be able to cancel proposal in Pending state', async () => { + let state: bigint + await createProposal('cancel pending proposal') + state = await governor.state(proposalId) + expect(state).to.equal(ProposalState.Pending) + await governor.connect(holders[0])['cancel(uint256)'](proposalId) + state = await governor.state(proposalId) + expect(state).to.equal(ProposalState.Canceled) + }) + it('should not be possible to cancel the proposal by proposalProposer if not in Pending state', async () => { await createProposal('should it be possible to cancel when not in pending?') await mine((await governor.votingDelay()) + 1n) @@ -455,12 +555,12 @@ describe('Governor Contact', () => { const state = await governor.state(proposalId) expect(state).to.equal(ProposalState.Active) - const tx = governor['cancel(uint256)'](proposalId) + const tx = governor.connect(holders[0])['cancel(uint256)'](proposalId) expect(tx).to.be.revertedWithCustomError({ interface: governor.interface }, unexpectedProposalState) }) - describe('guardian should be able to cancel proposals even if it is not proposalProposer', async () => { + describe('Guardian should be able to cancel proposals even if it is not proposalProposer', async () => { before(async () => { const dispenseTx = await rif.transfer(holders[1].address, dispenseValue) await dispenseTx.wait() @@ -501,6 +601,50 @@ describe('Governor Contact', () => { expect(tx).to.be.revertedWithCustomError({ interface: governor.interface }, unexpectedProposalState) }) + it('should NOT be possible to cancel Proposal.Defeated', async () => { + await createProposal('guardian cancelling defeated', holders[1]) + await mine((await governor.votingDelay()) + 1n) + + await mine(initialVotingDelay + initialVotingPeriod + 1n) + + expect(await getState()).to.be.equal(ProposalState.Defeated) + + const tx = governor.connect(deployer)['cancel(uint256)'](proposalId) + expect(tx).to.be.revertedWithCustomError({ interface: governor.interface }, unexpectedProposalState) + }) + + it('should NOT be possible to cancel Proposal.Executed', async () => { + const description = 'guardian cancelling Executed' + //create proposal + await createProposal(description, holders[1]) + await mine((await governor.votingDelay()) + 1n) + + await voteToSucceed() + expect(await getState()).to.be.equal(ProposalState.Succeeded) + + const needsQueuing = await governor.proposalNeedsQueuing(proposalId) + expect(needsQueuing).to.be.true + + await queueProposal() + const queuedState = await governor.state(proposalId) + expect(queuedState).to.be.equal(ProposalState.Queued) + + await time.increaseTo(eta) + const block = await ethers.provider.getBlock('latest') + expect(block?.timestamp).to.equal(eta) + + const executeTx = await governor['execute(address[],uint256[],bytes[],bytes32)']( + ...proposal, + generateDescriptionHash(description), + ) + await expect(executeTx).to.emit(governor, 'ProposalExecuted').withArgs(proposalId) + const state = await getState() + expect(state).to.equal(ProposalState.Executed) + + const tx = governor.connect(deployer)['cancel(uint256)'](proposalId) + expect(tx).to.be.revertedWithCustomError({ interface: governor.interface }, unexpectedProposalState) + }) + it('should be able to cancel ProposalState.Succceded', async () => { //cancelling ProposalState.Succceded as guardian await createProposal('guardian cancelling succeeded', holders[1]) diff --git a/test/OgFoundersEP.test.ts b/test/OgFoundersEP.test.ts index 53c449a..0bcf118 100644 --- a/test/OgFoundersEP.test.ts +++ b/test/OgFoundersEP.test.ts @@ -3,7 +3,7 @@ import hre, { ethers, ignition } from 'hardhat' import { OgFoundersEcosystemPartner } from '../typechain-types' import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' import { ogFoundersEpProxyModule } from '../ignition/modules/OgFoundersEP' -import deployParams from '../params/OgFoundersEP/testnet.json' +import airdropReceivers from '../params/OgFoundersEP/airdrop-testnet.json' describe('OgFoundersEcosystemPartner NFT', () => { let deployer: SignerWithAddress @@ -13,34 +13,74 @@ describe('OgFoundersEcosystemPartner NFT', () => { before(async () => { ;[deployer, alice] = await ethers.getSigners() - const contract = await ignition.deploy(ogFoundersEpProxyModule, { - parameters: deployParams, - }) + const contract = await ignition.deploy(ogFoundersEpProxyModule) + ogFoundersEp = contract.ogFoundersEp as unknown as OgFoundersEcosystemPartner // impersonating airdrop receivers - for (let i = 0; i < deployParams.OgFoundersEP.airdropAddresses.length; i++) { - const senderAddr = deployParams.OgFoundersEP.airdropAddresses[i] + for (let i = 0; i < airdropReceivers.length; i++) { + const accountAddr = airdropReceivers[i].receiver await hre.network.provider.request({ method: 'hardhat_impersonateAccount', - params: [senderAddr], + params: [accountAddr], }) - const sender = await ethers.getSigner(senderAddr) - oldGangsters.push(sender) + const account = await ethers.getSigner(accountAddr) + oldGangsters.push(account) } - ogFoundersEp = contract.ogFoundersEp as unknown as OgFoundersEcosystemPartner }) describe('Upon deployment', () => { it('should set up proper NFT name and symbol', async () => { - expect(await ogFoundersEp.connect(deployer).name()).to.equal(deployParams.OgFoundersEP.contractName) - expect(await ogFoundersEp.symbol()).to.equal(deployParams.OgFoundersEP.symbol) + expect(await ogFoundersEp.connect(deployer).name()).to.equal('OgFoundersEcosystemPartner') + expect(await ogFoundersEp.symbol()).to.equal('OGFEP') + }) + + it('should have zero total supply', async () => { + expect(await ogFoundersEp.totalSupply()).to.equal(0) }) - it('should have performed airdrop during deployment', async () => { - const ipfsCids = deployParams.OgFoundersEP.ipfsCids - for (let i = 0; i < oldGangsters.length; i++) { - expect(await ogFoundersEp.ownerOf(i + 1)).to.equal(oldGangsters[i].address) - expect(await ogFoundersEp.tokenURI(i + 1)).to.equal(`ipfs://${ipfsCids[i]}`) - } + it('should have an owner', async () => { + expect(await ogFoundersEp.owner()).to.equal(deployer.address) + }) + }) + + describe('Airdrop', () => { + it('should execute the initial airdrop after deployment', async () => { + await expect(ogFoundersEp.connect(deployer).airdrop(airdropReceivers)) + .to.emit(ogFoundersEp, 'AirdropExecuted') + .withArgs(airdropReceivers.length) + }) + it('the Gangsters should own NFTs after the airdrop', async () => { + await Promise.all( + oldGangsters.map(async (gangster, i) => { + expect(await ogFoundersEp.balanceOf(gangster.address)).to.equal(1) + // token IDs: 1, 2, 3... + expect(await ogFoundersEp.tokenOfOwnerByIndex(gangster.address, 0)).to.equal(i + 1) + }), + ) + }) + it('should top up total supply after the airdrop', async () => { + expect(await ogFoundersEp.totalSupply()).to.equal(airdropReceivers.length) + }) + it('non-owner cannot execute airdrop', async () => { + await expect(ogFoundersEp.connect(alice).airdrop(airdropReceivers)) + .to.be.revertedWithCustomError(ogFoundersEp, 'OwnableUnauthorizedAccount') + .withArgs(alice.address) + }) + it('should execute the second airdrop to the same addresses', async () => { + await expect(ogFoundersEp.connect(deployer).airdrop(airdropReceivers)) + .to.emit(ogFoundersEp, 'AirdropExecuted') + .withArgs(airdropReceivers.length) + }) + it('the Gangsters should own 2 NFTs after the second airdrop', async () => { + await Promise.all( + oldGangsters.map(async (gangster, i) => { + const tokenId = airdropReceivers.length + i + 1 + expect(await ogFoundersEp.balanceOf(gangster.address)).to.equal(2) + // token IDs: 6, 7, 8... + expect(await ogFoundersEp.tokenOfOwnerByIndex(gangster.address, 1)).to.equal(tokenId) + const cid = airdropReceivers[i].ipfsCid + expect(await ogFoundersEp.tokenURI(tokenId)).to.equal(`ipfs://${cid}`) + }), + ) }) })