From 6cc5071b2b535cebfd2cfefe429e0fa6f4a1a760 Mon Sep 17 00:00:00 2001 From: LordCheta Date: Fri, 6 Dec 2024 13:18:51 +0100 Subject: [PATCH 1/2] feat: implement betabuilders nft sc --- .../NFT/BetaBuildersRootstockCollective.sol | 48 +++++++ ignition/modules/BetaBuildersModule.ts | 22 ++++ .../BetaBuildersModule/airdrop-testnet.json | 22 ++++ template.env | 1 - test/BetaBuilders.test.ts | 118 ++++++++++++++++++ 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 contracts/NFT/BetaBuildersRootstockCollective.sol create mode 100644 ignition/modules/BetaBuildersModule.ts create mode 100644 params/BetaBuildersModule/airdrop-testnet.json delete mode 100644 template.env create mode 100644 test/BetaBuilders.test.ts diff --git a/contracts/NFT/BetaBuildersRootstockCollective.sol b/contracts/NFT/BetaBuildersRootstockCollective.sol new file mode 100644 index 0000000..326a96b --- /dev/null +++ b/contracts/NFT/BetaBuildersRootstockCollective.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// 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 {ERC721AirdroppableUpgradable} from "./ERC721AirdroppableUpgradable.sol"; +import {ERC721NonTransferrableUpgradable} from "./ERC721NonTransferrableUpgradable.sol"; + +contract BetaBuildersRootstockCollective is ERC721AirdroppableUpgradable, ERC721NonTransferrableUpgradable { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + function initialize(address initialOwner) public initializer { + __ERC721UpgradableBase_init("BetaBuildersRootstockCollective", "BB", initialOwner); + } + + function _authorizeUpgrade(address newImplementation) internal virtual override onlyOwner {} + + 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, ERC721NonTransferrableUpgradable) { + super.approve(to, tokenId); + } + + function setApprovalForAll( + address operator, + bool approved + ) public virtual override(IERC721, ERC721Upgradeable, ERC721NonTransferrableUpgradable) { + super.setApprovalForAll(operator, approved); + } + + function transferFrom( + address from, + address to, + uint256 tokenId + ) public virtual override(IERC721, ERC721Upgradeable, ERC721NonTransferrableUpgradable) { + super.transferFrom(from, to, tokenId); + } +} diff --git a/ignition/modules/BetaBuildersModule.ts b/ignition/modules/BetaBuildersModule.ts new file mode 100644 index 0000000..e873816 --- /dev/null +++ b/ignition/modules/BetaBuildersModule.ts @@ -0,0 +1,22 @@ +import { buildModule } from '@nomicfoundation/hardhat-ignition/modules' + +export const betaBuildersModule = buildModule('BetaBuilders', m => { + // deploy implementation + const implementation = m.contract('BetaBuildersRootstockCollective', [], { id: 'Implementation' }) + + const deployer = m.getAccount(0) + // deploy proxy + const proxy = m.contract('ERC1967Proxy', [ + implementation, + m.encodeFunctionCall(implementation, 'initialize', [deployer], { + id: 'Proxy', + }), + ]) + const ExtBetaBuildersEP = m.contractAt('BetaBuildersRootstockCollective', proxy, { + id: 'Contract', + }) + + return { ExtBetaBuildersEP } +}) + +export default betaBuildersModule diff --git a/params/BetaBuildersModule/airdrop-testnet.json b/params/BetaBuildersModule/airdrop-testnet.json new file mode 100644 index 0000000..5c605eb --- /dev/null +++ b/params/BetaBuildersModule/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/template.env b/template.env deleted file mode 100644 index 6741a1f..0000000 --- a/template.env +++ /dev/null @@ -1 +0,0 @@ -MNEMONIC="" \ No newline at end of file diff --git a/test/BetaBuilders.test.ts b/test/BetaBuilders.test.ts new file mode 100644 index 0000000..0e599d0 --- /dev/null +++ b/test/BetaBuilders.test.ts @@ -0,0 +1,118 @@ +import { expect } from 'chai' +import hre, { ethers, ignition } from 'hardhat' +import { BetaBuildersRootstockCollective } from '../typechain-types' +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers' +import { betaBuildersModule } from '../ignition/modules/BetaBuildersModule' +import airdropReceivers from '../params/BetaBuildersModule/airdrop-testnet.json' + +describe('BetaBuildersRootstockCollective NFT', () => { + let deployer: SignerWithAddress + let alice: SignerWithAddress + const oldGangsters: SignerWithAddress[] = [] + let betaBuildersEP: BetaBuildersRootstockCollective + + before(async () => { + ;[deployer, alice] = await ethers.getSigners() + const contract = await ignition.deploy(betaBuildersModule) + betaBuildersEP = contract.ExtBetaBuildersEP as unknown as BetaBuildersRootstockCollective + // impersonating airdrop receivers + for (let i = 0; i < airdropReceivers.length; i++) { + const accountAddr = airdropReceivers[i].receiver + await hre.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [accountAddr], + }) + const account = await ethers.getSigner(accountAddr) + oldGangsters.push(account) + } + }) + + describe('Upon deployment', () => { + it('should set up proper NFT name and symbol', async () => { + expect(await betaBuildersEP.connect(deployer).name()).to.equal('BetaBuildersRootstockCollective') + expect(await betaBuildersEP.symbol()).to.equal('BB') + }) + + it('should have zero total supply', async () => { + expect(await betaBuildersEP.totalSupply()).to.equal(0) + }) + + it('should have an owner', async () => { + expect(await betaBuildersEP.owner()).to.equal(deployer.address) + }) + }) + + describe('Airdrop', () => { + it('should execute the initial airdrop after deployment', async () => { + await expect(betaBuildersEP.connect(deployer).airdrop(airdropReceivers)) + .to.emit(betaBuildersEP, 'AirdropExecuted') + .withArgs(airdropReceivers.length) + }) + it('the Gangsters should own NFTs after the airdrop', async () => { + await Promise.all( + oldGangsters.map(async (gangster, i) => { + expect(await betaBuildersEP.balanceOf(gangster.address)).to.equal(1) + // token IDs: 1, 2, 3... + expect(await betaBuildersEP.tokenOfOwnerByIndex(gangster.address, 0)).to.equal(i + 1) + }), + ) + }) + it('should top up total supply after the airdrop', async () => { + expect(await betaBuildersEP.totalSupply()).to.equal(airdropReceivers.length) + }) + it('non-owner cannot execute airdrop', async () => { + await expect(betaBuildersEP.connect(alice).airdrop(airdropReceivers)) + .to.be.revertedWithCustomError(betaBuildersEP, 'OwnableUnauthorizedAccount') + .withArgs(alice.address) + }) + it('should execute the second airdrop to the same addresses', async () => { + await expect(betaBuildersEP.connect(deployer).airdrop(airdropReceivers)) + .to.emit(betaBuildersEP, '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 betaBuildersEP.balanceOf(gangster.address)).to.equal(2) + // token IDs: 6, 7, 8... + expect(await betaBuildersEP.tokenOfOwnerByIndex(gangster.address, 1)).to.equal(tokenId) + const cid = airdropReceivers[i].ipfsCid + expect(await betaBuildersEP.tokenURI(tokenId)).to.equal(`ipfs://${cid}`) + }), + ) + }) + }) + + describe('Transfer functionality is disabled', () => { + it('transfers should be forbidden after airdrop', async () => { + await Promise.all( + oldGangsters.map(async (sender, i) => { + await expect( + betaBuildersEP.connect(sender).transferFrom(sender.address, alice.address, i + 1), + ).to.be.revertedWithCustomError(betaBuildersEP, 'TransfersDisabled') + }), + ) + }) + + it('approvals should be forbidden', async () => { + await Promise.all( + oldGangsters.map(async (sender, i) => { + await expect( + betaBuildersEP.connect(sender).approve(alice.address, i + 1), + ).to.be.revertedWithCustomError(betaBuildersEP, 'TransfersDisabled') + }), + ) + }) + + it('setApprovalForAll should be forbidden', async () => { + await Promise.all( + oldGangsters.map(async sender => { + await expect( + betaBuildersEP.connect(sender).setApprovalForAll(alice.address, true), + ).to.be.revertedWithCustomError(betaBuildersEP, 'TransfersDisabled') + }), + ) + }) + }) +}) From c589801147f79a59c7164c727988c250e46e864d Mon Sep 17 00:00:00 2001 From: LordCheta Date: Fri, 6 Dec 2024 14:29:28 +0100 Subject: [PATCH 2/2] chore: re-add .env template file --- template.env | 1 + 1 file changed, 1 insertion(+) create mode 100644 template.env diff --git a/template.env b/template.env new file mode 100644 index 0000000..6741a1f --- /dev/null +++ b/template.env @@ -0,0 +1 @@ +MNEMONIC="" \ No newline at end of file