From e3568ac5ea662efeda9510d26631faa6809ee374 Mon Sep 17 00:00:00 2001 From: mzxyz <8177474+mzxyz@users.noreply.github.com> Date: Thu, 4 Jan 2024 20:42:06 +1300 Subject: [PATCH] Build `SQTGift` NFT contract (#309) * Build `SQTGift` NFT contract * Add `SQTRedeem` contract * Update `redeem` function * Update gift contracts * Add extra condition to redeem * Add more events * Update contracts/SQTGift.sol Co-authored-by: Ian He <39037239+ianhe8x@users.noreply.github.com> * Update contracts/SQTGift.sol Co-authored-by: Ian He <39037239+ianhe8x@users.noreply.github.com> * Update revert codes for `SQTGift` * Update revert code for `SQTRedeem` * remove redeem logic from SQTGift * improve test * clean up * add sqtGift to ts * add batchMint() and publish to testnet --------- Co-authored-by: Ian He <39037239+ianhe8x@users.noreply.github.com> --- contracts/SQTGift.sol | 161 ++++++++++++++++++++++++++++++ contracts/SQTRedeem.sol | 68 +++++++++++++ contracts/interfaces/ISQTGift.sol | 21 ++++ hardhat.config.ts | 9 ++ publish/revertcode.json | 16 ++- publish/testnet.json | 8 +- scripts/contracts.ts | 4 + scripts/deployContracts.ts | 11 +- src/contracts.ts | 2 + src/sdk.ts | 4 +- src/types.ts | 2 + test/SQTGift.test.ts | 108 ++++++++++++++++++++ 12 files changed, 409 insertions(+), 5 deletions(-) create mode 100644 contracts/SQTGift.sol create mode 100644 contracts/SQTRedeem.sol create mode 100644 contracts/interfaces/ISQTGift.sol create mode 100644 test/SQTGift.test.ts diff --git a/contracts/SQTGift.sol b/contracts/SQTGift.sol new file mode 100644 index 00000000..0ad60cac --- /dev/null +++ b/contracts/SQTGift.sol @@ -0,0 +1,161 @@ +// Copyright (C) 2020-2023 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.15; + +import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; +import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; + +import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; +import '@openzeppelin/contracts-upgradeable/utils/introspection/ERC165CheckerUpgradeable.sol'; +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +import './interfaces/ISQTGift.sol'; + +contract SQTGift is Initializable, OwnableUpgradeable, ERC721Upgradeable, ERC721URIStorageUpgradeable, ERC721EnumerableUpgradeable, ISQTGift { + + uint256 public nextSeriesId; + + /// @notice seriesId => GiftSeries + mapping(uint256 => GiftSeries) public series; + + /// @notice account => seriesId => gift count + mapping(address => mapping(uint256 => uint8)) public allowlist; + + /// @notice tokenId => Gift + mapping(uint256 => Gift) public gifts; + + event AllowListAdded(address indexed account, uint256 indexed seriesId, uint8 amount); + event AllowListRemoved(address indexed account, uint256 indexed seriesId, uint8 amount); + + event SeriesCreated(uint256 indexed seriesId, uint256 maxSupply, string tokenURI); + event SeriesActiveUpdated(uint256 indexed seriesId, bool active); + + event GiftMinted(address indexed to, uint256 indexed seriesId, uint256 indexed tokenId, string tokenURI); + + function initialize() external initializer { + __Ownable_init(); + __ERC721_init("SQT Gift", "SQTG"); + __ERC721URIStorage_init(); + __ERC721Enumerable_init(); + } + + function batchAddToAllowlist(uint256[] calldata _seriesId, address[] calldata _address, uint8[] calldata _amount) public onlyOwner { + require(_seriesId.length == _address.length, 'SQG003'); + require(_seriesId.length == _amount.length, 'SQG003'); + for (uint256 i = 0; i < _seriesId.length; i++) { + addToAllowlist(_seriesId[i], _address[i], _amount[i]); + } + } + + function addToAllowlist(uint256 _seriesId, address _address, uint8 _amount) public onlyOwner { + require(series[_seriesId].maxSupply > 0, "SQG001"); + allowlist[_address][_seriesId] += _amount; + + emit AllowListAdded(_address, _seriesId, _amount); + } + + function removeFromAllowlist(uint256 _seriesId, address _address, uint8 _amount) public onlyOwner { + require(series[_seriesId].maxSupply > 0, "SQG001"); + require(allowlist[_address][_seriesId] >= _amount, "SQG002"); + allowlist[_address][_seriesId] -= _amount; + + emit AllowListRemoved(_address, _seriesId, _amount); + } + + function createSeries( + uint256 _maxSupply, + string memory _tokenURI + ) external onlyOwner { + require(_maxSupply > 0, "SQG006"); + series[nextSeriesId] = GiftSeries({ + maxSupply: _maxSupply, + totalSupply: 0, + active: true, + tokenURI: _tokenURI + }); + + emit SeriesCreated(nextSeriesId, _maxSupply, _tokenURI); + + nextSeriesId += 1; + + } + + function setSeriesActive(uint256 _seriesId, bool _active) external onlyOwner { + require(series[_seriesId].maxSupply > 0, "SQG001"); + series[_seriesId].active = _active; + + emit SeriesActiveUpdated(_seriesId, _active); + } + + function setMaxSupply(uint256 _seriesId, uint256 _maxSupply) external onlyOwner { + require(_maxSupply > 0, "SQG006"); + series[_seriesId].maxSupply = _maxSupply; + } + + function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize) internal override( + ERC721Upgradeable, + ERC721EnumerableUpgradeable + ) { + super._beforeTokenTransfer(from, to, tokenId, batchSize); + } + + function supportsInterface(bytes4 interfaceId) public view override( + IERC165Upgradeable, + ERC721Upgradeable, + ERC721EnumerableUpgradeable, + ERC721URIStorageUpgradeable + ) returns (bool) { + return interfaceId == type(ISQTGift).interfaceId || super.supportsInterface(interfaceId); + } + + function tokenURI(uint256 tokenId) public view override( + ERC721Upgradeable, + ERC721URIStorageUpgradeable + ) returns (string memory) { + return super.tokenURI(tokenId); + } + + function _burn(uint256 tokenId) internal override(ERC721Upgradeable, ERC721URIStorageUpgradeable) { + super._burn(tokenId); + } + + function _baseURI() internal view virtual override returns (string memory) { + return "ipfs://"; + } + + function mint(uint256 _seriesId) public { + GiftSeries memory giftSerie = series[_seriesId]; + require(giftSerie.active, "SQG004"); + require(allowlist[msg.sender][_seriesId] > 0, "SQG002"); + + require(giftSerie.totalSupply < giftSerie.maxSupply, "SQG005"); + series[_seriesId].totalSupply += 1; + + uint256 tokenId = totalSupply() + 1; + gifts[tokenId].seriesId = _seriesId; + + _safeMint(msg.sender, tokenId); + _setTokenURI(tokenId, giftSerie.tokenURI); + + allowlist[msg.sender][_seriesId]--; + + emit GiftMinted(msg.sender, _seriesId, tokenId, giftSerie.tokenURI); + } + + function batchMint(uint256 _seriesId) external { + GiftSeries memory giftSerie = series[_seriesId]; + require(giftSerie.active, "SQG004"); + uint8 allowAmount = allowlist[msg.sender][_seriesId]; + require(allowAmount > 0, "SQG002"); + for (uint256 i = 0; i < allowAmount; i++) { + mint(_seriesId); + } + } + + function getSeries(uint256 tokenId) external view returns (uint256) { + return gifts[tokenId].seriesId; + } +} \ No newline at end of file diff --git a/contracts/SQTRedeem.sol b/contracts/SQTRedeem.sol new file mode 100644 index 00000000..e49eb1ad --- /dev/null +++ b/contracts/SQTRedeem.sol @@ -0,0 +1,68 @@ +// Copyright (C) 2020-2023 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.15; + +import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; +import '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; + +import './interfaces/ISQTGift.sol'; + +contract SQTRedeem is Initializable, OwnableUpgradeable { + function initialize(address _sqtoken) external initializer {} +// +// address public sqtoken; +// +// bool public redeemable; +// +// mapping (address => bool) public allowlist; +// +// event SQTRedeemed(address indexed to, address nft, uint256 indexed tokenId, uint256 sqtValue); +// +// function initialize(address _sqtoken) external initializer { +// __Ownable_init(); +// +// sqtoken = _sqtoken; +// } +// +// function desposit(uint256 amount) public onlyOwner { +// require(IERC20(sqtoken).transferFrom(msg.sender, address(this), amount), 'SQR001'); +// } +// +// function withdraw(uint256 amount) public onlyOwner { +// require(IERC20(sqtoken).transfer(msg.sender, amount), 'SQR001'); +// } +// +// function addToAllowlist(address _address) public onlyOwner { +// allowlist[_address] = true; +// } +// +// function removeFromAllowlist(address _address) public onlyOwner { +// allowlist[_address] = false; +// } +// +// function setRedeemable(bool _redeemable) external onlyOwner { +// redeemable = _redeemable; +// } +// +// function redeem(address nft, uint256 tokenId) public { +// require(redeemable, "SQR002"); +// require(allowlist[nft], "SQR003"); +// +// IERC165Upgradeable nftContract = IERC165Upgradeable(nft); +// require(nftContract.supportsInterface(type(ISQTGift).interfaceId), "SQR004"); +// +// ISQTGift sqtGift = ISQTGift(nft); +// require(sqtGift.getGiftRedeemable(tokenId), "SQG005"); +// require(sqtGift.ownerOf(tokenId) == msg.sender, "SQG006"); +// uint256 sqtValue = sqtGift.getSQTRedeemableValue(tokenId); +// require(sqtValue > 0, "SQG007"); +// sqtGift.afterTokenRedeem(tokenId); +// +// require(IERC20(sqtoken).transfer(msg.sender, sqtValue), "SQR001"); +// +// emit SQTRedeemed(msg.sender, nft, tokenId, sqtValue); +// } +} \ No newline at end of file diff --git a/contracts/interfaces/ISQTGift.sol b/contracts/interfaces/ISQTGift.sol new file mode 100644 index 00000000..1bf5f186 --- /dev/null +++ b/contracts/interfaces/ISQTGift.sol @@ -0,0 +1,21 @@ +// Copyright (C) 2020-2023 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity 0.8.15; + +import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; + +struct GiftSeries { + uint256 maxSupply; + uint256 totalSupply; + bool active; + string tokenURI; +} + +struct Gift { + uint256 seriesId; +} + +interface ISQTGift is IERC721Upgradeable { + function getSeries(uint256 tokenId) external view returns (uint256); +} \ No newline at end of file diff --git a/hardhat.config.ts b/hardhat.config.ts index 98c8b074..66ebe660 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -335,6 +335,15 @@ task('publishChild', "verify and publish contracts on etherscan") address: deployment.TokenExchange.address, constructorArguments: [], }); + //SQTGift + await hre.run("verify:verify", { + address: deployment.SQTGift.address, + constructorArguments: [deployment.SQTGift.innerAddress, deployment.ProxyAdmin.address, []], + }); + await hre.run("verify:verify", { + address: deployment.SQTGift.innerAddress, + constructorArguments: [], + }); } catch (err) { console.log(err); diff --git a/publish/revertcode.json b/publish/revertcode.json index 2563bf63..2507675b 100644 --- a/publish/revertcode.json +++ b/publish/revertcode.json @@ -180,5 +180,19 @@ "TE001": "Token give balance must be greater than 0", "TE002": "order not exist", "TE003": "trade amount exceed order balance", - "PD001": "RootChainManager is undefined" + "PD001": "RootChainManager is undefined", + "SQG001": "Series not found", + "SQG002": "Not on allowlist", + "SQG003": "Invalid Batch Parameters", + "SQG004": "Series not active", + "SQG005": "Max gift supply reached", + "SQG006": "Max supply must be greater than 0", + + "SQR001": "Failed to transfer SQT", + "SQR002": "Redeem not enabled", + "SQR003": "NFT not allowed to redeem", + "SQR004": "NFT does not support ISQTGift", + "SQR005": "Gift not redeemable", + "SQR006": "Not owner of token", + "SQR007": "No SQT to redeem" } diff --git a/publish/testnet.json b/publish/testnet.json index d081f015..f5e39fb1 100644 --- a/publish/testnet.json +++ b/publish/testnet.json @@ -20,7 +20,7 @@ }, "SQToken": { "innerAddress": "", - "address": "0xAFD07FAB547632d574b38A72EDAE93fA23d1E7d7", + "address": "r", "bytecodeHash": "b36d78daa299ef0902228a52e74c6016998ac240026c54624ba882e15d4a29cc", "lastUpdate": "Mon, 11 Dec 2023 07:37:28 GMT" }, @@ -188,6 +188,12 @@ "bytecodeHash": "58b29e3ecad69575fb0ea5f4699ff93df771c4ec78c857f0f6f711840f2192b3", "lastUpdate": "Fri, 15 Dec 2023 06:11:39 GMT" }, + "SQTGift": { + "innerAddress": "0x05fC60d66d4386C14145c88f3b92Fb55642452c0", + "address": "0xCe008ea6ef4B7C5712B8B8DF7A4ca021859ab266", + "bytecodeHash": "3d7cdd1cbad232b4d9d6804bffbdf9f64804c89c0fab972e8a5839b6b51ee34a", + "lastUpdate": "Thu, 04 Jan 2024 02:58:39 GMT" + }, "Airdropper": { "innerAddress": "", "address": "0xD01AE239CFDf49d88F1c3ab6d2c82b7f411C71b1", diff --git a/scripts/contracts.ts b/scripts/contracts.ts index a8e302ee..53fbcb78 100644 --- a/scripts/contracts.ts +++ b/scripts/contracts.ts @@ -64,6 +64,8 @@ import { PolygonDestination, PolygonDestination__factory, ChildERC20__factory, + SQTGift__factory, + SQTGift, VTSQToken, VTSQToken__factory, } from '../src'; @@ -105,6 +107,7 @@ export type Contracts = { consumerRegistry: ConsumerRegistry; priceOracle: PriceOracle; polygonDestination: PolygonDestination; + sqtGift: SQTGift; }; export const UPGRADEBAL_CONTRACTS: Partial> = @@ -164,6 +167,7 @@ export const CONTRACT_FACTORY: Record = { PriceOracle: PriceOracle__factory, ChildERC20: ChildERC20__factory, PolygonDestination: PolygonDestination__factory, + SQTGift: SQTGift__factory, }; export type Config = number | string | string[]; diff --git a/scripts/deployContracts.ts b/scripts/deployContracts.ts index 59fa2fad..a46f415d 100644 --- a/scripts/deployContracts.ts +++ b/scripts/deployContracts.ts @@ -43,6 +43,7 @@ import { TokenExchange, PolygonDestination, RootChainManager__factory, + SQTGift, Airdropper, VTSQToken, } from '../src'; @@ -436,13 +437,18 @@ export async function deployContracts( initConfig: [10, 3600], }); + // delpoy PriceOracle contract + const sqtGift = await deployContract('SQTGift', 'child', { + proxyAdmin, + initConfig: [], + }); + //deploy Airdropper contract const [settleDestination] = config['Airdropper']; const airdropper = await deployContract('Airdropper', 'child', { deployConfig: [settleDestination] }); // Register addresses on settings contract - // FIXME: failed to send this tx - logger?.info('🤞 Set token addresses'); + logger?.info('🤞 Set settings addresses'); const txToken = await settings.setBatchAddress([ SQContracts.SQToken, SQContracts.Staking, @@ -510,6 +516,7 @@ export async function deployContracts( tokenExchange, priceOracle, consumerRegistry, + sqtGift, airdropper, }, ]; diff --git a/src/contracts.ts b/src/contracts.ts index ec7af94e..5c1d06f3 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -31,6 +31,7 @@ import ChildERC20 from './artifacts/contracts/polygon/ChildERC20.sol/ChildERC20. import Vesting from './artifacts/contracts/root/Vesting.sol/Vesting.json'; import VTSQToken from './artifacts/contracts/root/VTSQToken.sol/VTSQToken.json'; import PolygonDestination from './artifacts/contracts/root/PolygonDestination.sol/PolygonDestination.json'; +import SQTGift from "./artifacts/contracts/SQTGift.sol/SQTGift.json"; export default { Settings, @@ -63,4 +64,5 @@ export default { ConsumerRegistry, ChildERC20, PolygonDestination, + SQTGift, }; diff --git a/src/sdk.ts b/src/sdk.ts index 42aaab21..fc48e657 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -31,7 +31,7 @@ import { StateChannel, VSQToken, Vesting, - TokenExchange + TokenExchange, SQTGift } from './typechain'; import { CONTRACT_FACTORY, @@ -45,6 +45,7 @@ import assert from "assert"; // HOTFIX: Contract names are not consistent between deployments and privous var names const contractNameConversion: Record = { sQToken: 'sqToken', + sQTGift: 'sqtGift', rewardsDistributer: 'rewardsDistributor', }; @@ -75,6 +76,7 @@ export class ContractSDK { readonly priceOracle!: PriceOracle; readonly vSQToken!: VSQToken; readonly tokenExchange!: TokenExchange; + readonly sqtGift!: SQTGift; constructor(private readonly signerOrProvider: AbstractProvider | Signer, public readonly options: SdkOptions) { assert(this.options.deploymentDetails || DEPLOYMENT_DETAILS[options.network],' missing contract deployment info'); diff --git a/src/types.ts b/src/types.ts index 3ceb68a9..1e064223 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,7 @@ import { ChildERC20__factory, TokenExchange__factory, PolygonDestination__factory, + SQTGift__factory, } from './typechain'; export type SubqueryNetwork = 'testnet' | 'mainnet' | 'local'; @@ -125,6 +126,7 @@ export const CONTRACT_FACTORY: Record = { ConsumerRegistry: ConsumerRegistry__factory, ChildERC20: ChildERC20__factory, PolygonDestination: PolygonDestination__factory, + SQTGift: SQTGift__factory, }; export enum SQContracts { diff --git a/test/SQTGift.test.ts b/test/SQTGift.test.ts new file mode 100644 index 00000000..3dde5f6d --- /dev/null +++ b/test/SQTGift.test.ts @@ -0,0 +1,108 @@ +// Copyright (C) 2020-2023 SubQuery Pte Ltd authors & contributors +// SPDX-License-Identifier: GPL-3.0-or-later + +import { expect } from 'chai'; +import { ethers, waffle } from 'hardhat'; +import { eventFrom, eventsFrom } from './helper'; +import { deployContracts } from "./setup"; +import { SQTGift } from '../src'; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +describe('SQT Gift Nft', () => { + const mockProvider = waffle.provider; + let wallet_0: SignerWithAddress, wallet_1: SignerWithAddress, wallet_2: SignerWithAddress; + let nft: SQTGift; + + const deployer = async () => { + const deployment = await deployContracts(wallet_0, wallet_1); + return deployment.sqtGift; + }; + before(async () => { + [wallet_0, wallet_1, wallet_2] = await ethers.getSigners(); + }); + + beforeEach(async () => { + nft = await waffle.loadFixture(deployer); + }); + + describe('Serie Config', () => { + it('add series', async () => { + const tx = await nft.createSeries(100, "abc"); + const event = await eventFrom(tx, nft, 'SeriesCreated(uint256,uint256,string)'); + expect(event.seriesId).to.eq(0); + expect(event.maxSupply).to.eq(100); + expect(event.tokenURI).to.eq("abc"); + }); + it('add allowList', async () => { + await nft.createSeries(100, "abc"); + const tx = await nft.addToAllowlist(0, wallet_1.address, 1); + const event = await eventFrom(tx, nft, 'AllowListAdded(address,uint256,uint8)'); + expect(event.account).to.eq(wallet_1.address); + expect(event.seriesId).to.eq(0); + }); + it('add allowList for non exist series', async () => { + await expect(nft.addToAllowlist(0, wallet_1.address, 1)).to.revertedWith('SQG001'); + await nft.createSeries(100, "abc"); + await expect(nft.addToAllowlist(0, wallet_1.address, 1)).not.reverted; + }); + it('batch add allowList', async () => { + await nft.createSeries(100, "token0"); + await nft.createSeries(100, "token1"); + const tx = await nft.batchAddToAllowlist( + [0,0,1,0], + [wallet_1.address,wallet_2.address,wallet_1.address,wallet_1.address], + [1,2,1,5]); + const events = await eventsFrom(tx, nft, 'AllowListAdded(address,uint256,uint8)'); + expect(events.length).to.eq(4); + expect(await nft.allowlist(wallet_1.address,0)).to.eq(6); + expect(await nft.allowlist(wallet_1.address,1)).to.eq(1); + expect(await nft.allowlist(wallet_2.address,0)).to.eq(2); + expect(await nft.allowlist(wallet_2.address,1)).to.eq(0); + }) + }); + + describe('Mint Tokens', () => { + beforeEach(async () => { + await nft.createSeries(100, "series0"); + await nft.createSeries(50, "series1"); + await nft.addToAllowlist(0, wallet_1.address, 1); + await nft.addToAllowlist(1, wallet_1.address, 1); + }) + it('mint with allowed wallet', async () => { + let tx = await nft.connect(wallet_1).mint(0); + const event = await eventFrom(tx, nft, 'Transfer(address,address,uint256)') + expect(await nft.ownerOf(event.tokenId)).to.eq(wallet_1.address); + tx = await nft.connect(wallet_1).mint(1); + const event2 = await eventFrom(tx, nft, 'Transfer(address,address,uint256)') + expect(event.tokenId).not.eq(event2.tokenId); + }); + it('mint with allowed wallet 2', async () => { + await nft.connect(wallet_1).mint(0); + await expect(nft.connect(wallet_1).mint(0)).to.revertedWith('SQG002'); + await nft.addToAllowlist(0, wallet_1.address, 1); + await nft.connect(wallet_1).mint(0); + const token2Series = await nft.getSeries(1); + expect(token2Series).to.eq(0); + }); + it('can not mint when exceed max supply', async () => { + // series 2 + await nft.createSeries(1, "series1"); + await nft.addToAllowlist(2, wallet_1.address, 2); + await nft.connect(wallet_1).mint(2); + await expect(nft.connect(wallet_1).mint(2)).to.revertedWith('SQG005'); + await nft.setMaxSupply(2, 2); + await expect(nft.connect(wallet_1).mint(2)).not.reverted; + }); + it('can not mint when deactived', async () => { + await nft.setSeriesActive(0, false); + await expect(nft.connect(wallet_1).mint(0)).to.revertedWith('SQG004'); + await nft.setSeriesActive(0, true); + await expect(nft.connect(wallet_1).mint(0)).not.reverted; + }); + it('can batch mint token', async () => { + await nft.addToAllowlist(0, wallet_2.address, 10); + await nft.connect(wallet_2).batchMint(0); + expect(await nft.balanceOf(wallet_2.address)).to.eq(10); + }) + }); +});