From 8905805a0e7041fb8656cfe62439f52ca351566a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Ph=E1=BA=A1m?= Date: Thu, 11 Jan 2024 17:50:33 +0700 Subject: [PATCH] contract for airdrop token erc1155 --- contracts/FeralFileAirdropV1.sol | 106 ++++++++++ migrations/310_feralfile_airdrop_v1.js | 14 ++ package-lock.json | 15 +- package.json | 2 +- test/feralfile_airdrop_v1.js | 256 +++++++++++++++++++++++++ truffle-config.js | 204 ++++++++++---------- 6 files changed, 491 insertions(+), 106 deletions(-) create mode 100644 contracts/FeralFileAirdropV1.sol create mode 100644 migrations/310_feralfile_airdrop_v1.js create mode 100644 test/feralfile_airdrop_v1.js diff --git a/contracts/FeralFileAirdropV1.sol b/contracts/FeralFileAirdropV1.sol new file mode 100644 index 0000000..3853255 --- /dev/null +++ b/contracts/FeralFileAirdropV1.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import "./Authorizable.sol"; + +contract FeralFileAirdropV1 is ERC1155, Authorizable { + enum Type { + Fungible, + NonFungible + } + + // Version + string public constant version = "v1"; + + // Airdropped addresses + mapping(address => bool) public airdroppedAddresses; + + // already ended flag + bool private _ended; + + // Token type + Type public tokenType; + + constructor(Type tokenType_, string memory tokenURI_) ERC1155(tokenURI_) { + tokenType = tokenType_; + } + + // @notice check if the airdrop is ended + function isEnded() external view returns (bool) { + return _ended; + } + + // @notice end the airdrop + function end() external onlyOwner { + require(_ended == false, "FeralFileTokenAirdrop: already ended"); + _ended = true; + } + + /// @notice Mint tokens to the contract + /// @param tokenID token ID + /// @param amount_ amount of tokens to mint + function mint(uint256 tokenID, uint256 amount_) external onlyAuthorized { + require(_ended == false, "FeralFileTokenAirdrop: already ended"); + require( + (tokenType == Type.Fungible && amount_ > 0) || + (tokenType == Type.NonFungible && amount_ == 1), + "FeralFileTokenAirdrop: amount mismatch" + ); + _mint(address(this), tokenID, amount_, ""); + } + + /// @notice airdrop tokens to a list of addresses + /// @param tokenID token ID + /// @param to_ list of addresses to airdrop to + function airdrop( + uint256 tokenID, + address[] calldata to_ + ) external onlyAuthorized { + require(_ended == false, "FeralFileTokenAirdrop: already ended"); + require( + (tokenType == Type.Fungible && to_.length > 0) || + (tokenType == Type.NonFungible && to_.length == 1), + "FeralFileTokenAirdrop: amount mismatch" + ); + + uint256[] memory _tokenIDs = new uint256[](1); + _tokenIDs[0] = tokenID; + + for (uint256 i = 0; i < to_.length; i++) { + require( + airdroppedAddresses[to_[i]] == false, + "FeralFileTokenAirdrop: already airdropped" + ); + + uint256[] memory _amounts = new uint256[](1); + _amounts[0] = 1; // 1 token for each address + + _safeBatchTransferFrom( + address(this), + to_[i], + _tokenIDs, + _amounts, + "" + ); + + airdroppedAddresses[to_[i]] = true; + } + } + + /// @notice burn remaining tokens + /// @param tokenID token ID + function burnRemaining(uint256 tokenID) external onlyOwner { + _burn(address(this), tokenID, balanceOf(address(this), tokenID)); + } + + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes memory + ) public pure returns (bytes4) { + return this.onERC1155Received.selector; + } +} diff --git a/migrations/310_feralfile_airdrop_v1.js b/migrations/310_feralfile_airdrop_v1.js new file mode 100644 index 0000000..b9b844a --- /dev/null +++ b/migrations/310_feralfile_airdrop_v1.js @@ -0,0 +1,14 @@ +const FeralFileAirdropV1 = artifacts.require("FeralFileAirdropV1"); + +const argv = require("minimist")(process.argv.slice(2), { + string: ["token_uri", "type"], +}); + +module.exports = function (deployer) { + let token_uri = + argv.token_uri || + "https://ipfs.bitmark.com/ipfs/QmNVdQSp1AvZonLwHzTbbZDPLgbpty15RMQrbPEWd4ooTU/{id}"; + let type = argv.type || 0; + + deployer.deploy(FeralFileAirdropV1, type, token_uri); +}; diff --git a/package-lock.json b/package-lock.json index cbe8e70..ea91ed9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "solidity-coverage": "^0.7.22", "truffle": "^5.9.2", "truffle-flattener": "^1.6.0", - "truffle-plugin-verify": "^0.5.28" + "truffle-plugin-verify": "^0.5.33" } }, "node_modules/@ampproject/remapping": { @@ -16321,9 +16321,10 @@ } }, "node_modules/truffle-plugin-verify": { - "version": "0.5.28", - "resolved": "https://registry.npmjs.org/truffle-plugin-verify/-/truffle-plugin-verify-0.5.28.tgz", - "integrity": "sha512-wmWJsJPq9Zj/FaJVXISwnL0mOGGKLgSCbOnwXzxmuv+0iRcUuccFAianYP4Ru/QjzsD2T6pQ7TUjVGLEcCkMtg==", + "version": "0.5.33", + "resolved": "https://registry.npmjs.org/truffle-plugin-verify/-/truffle-plugin-verify-0.5.33.tgz", + "integrity": "sha512-NonyWylAVAjqHsvBe61iUpWmHQoN6wvz7OaNzIfyHO8+O5ZErPT/lhv+zRT31OLeFOanM403FySY3A/kzpl6fg==", + "deprecated": "Truffle was sunset, so this package will be deprecated alongside Truffle", "dev": true, "dependencies": { "axios": "^0.26.1", @@ -30669,9 +30670,9 @@ } }, "truffle-plugin-verify": { - "version": "0.5.28", - "resolved": "https://registry.npmjs.org/truffle-plugin-verify/-/truffle-plugin-verify-0.5.28.tgz", - "integrity": "sha512-wmWJsJPq9Zj/FaJVXISwnL0mOGGKLgSCbOnwXzxmuv+0iRcUuccFAianYP4Ru/QjzsD2T6pQ7TUjVGLEcCkMtg==", + "version": "0.5.33", + "resolved": "https://registry.npmjs.org/truffle-plugin-verify/-/truffle-plugin-verify-0.5.33.tgz", + "integrity": "sha512-NonyWylAVAjqHsvBe61iUpWmHQoN6wvz7OaNzIfyHO8+O5ZErPT/lhv+zRT31OLeFOanM403FySY3A/kzpl6fg==", "dev": true, "requires": { "axios": "^0.26.1", diff --git a/package.json b/package.json index 1fa70cf..1998cef 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,6 @@ "solidity-coverage": "^0.7.22", "truffle": "^5.9.2", "truffle-flattener": "^1.6.0", - "truffle-plugin-verify": "^0.5.28" + "truffle-plugin-verify": "^0.5.33" } } diff --git a/test/feralfile_airdrop_v1.js b/test/feralfile_airdrop_v1.js new file mode 100644 index 0000000..e4a1ad5 --- /dev/null +++ b/test/feralfile_airdrop_v1.js @@ -0,0 +1,256 @@ +const FeralFileAirdropV1 = artifacts.require("FeralFileAirdropV1"); +const TOKEN_URI = + "https://ipfs.bitmark.com/ipfs/QmNVdQSp1AvZonLwHzTbbZDPLgbpty15RMQrbPEWd4ooTU/{id}"; +const TOKEN_ID = 999; +const TOKEN_TYPE_FUNGIBLE = 0; +const TOKEN_TYPE_NON_FUNGIBLE = 1; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + +contract("FeralFileAirdropV1", async (accounts) => { + before(async () => { + this.owner = accounts[0]; + this.trustee = accounts[1]; + this.contracts = []; + + // To isolate the test cases, we create multiple pairs of contracts + // for each token type. The index of the contract pair will be used + // as index of test cases. + for (let i = 0; i < 5; i++) { + let fungibleContract = await FeralFileAirdropV1.new( + TOKEN_TYPE_FUNGIBLE, + TOKEN_URI + ); + await fungibleContract.addTrustee(this.trustee, { + from: this.owner, + }); + let nonFungibleContract = await FeralFileAirdropV1.new( + TOKEN_TYPE_NON_FUNGIBLE, + TOKEN_URI + ); + await nonFungibleContract.addTrustee(this.trustee, { + from: this.owner, + }); + + // Should follow the index of Type.Fungible and Type.NonFungible + // so that we could leverage the index for token type check + this.contracts[i] = [fungibleContract, nonFungibleContract]; + } + }); + + it("test constructor", async () => { + let contracts = this.contracts[0]; + for (let i = 0; i < contracts.length; i++) { + let contract = contracts[i]; + assert.equal(await contract.version(), "v1"); + assert.equal(await contract.uri(TOKEN_ID), TOKEN_URI); + assert.equal(await contract.isEnded(), false); + assert.equal(await contract.tokenType(), i); + } + }); + + it("test mint", async () => { + let contracts = this.contracts[1]; + for (let i = 0; i < contracts.length; i++) { + let contract = contracts[i]; + + // test unauthorized call + try { + await contract.mint(TOKEN_ID, 1, { + from: accounts[2], + }); + } catch (e) { + assert.ok( + e.hijackedStack.includes( + "VM Exception while processing transaction: revert" + ) + ); + } + + // test mint token with amount mismatch + let wrongAmount = i == 0 ? 0 : 2; + try { + await contract.mint(TOKEN_ID, wrongAmount, { + from: this.owner, + }); + } catch (e) { + assert.equal( + e.reason, + "FeralFileTokenAirdrop: amount mismatch" + ); + } + + // mint successfully + let correctAmount = i == 0 ? 10 : 1; + await contract.mint(TOKEN_ID, correctAmount, { from: this.owner }); + assert.equal( + await contract.balanceOf(contract.address, TOKEN_ID), + correctAmount + ); + } + }); + + it("test airdrop", async () => { + let contracts = this.contracts[2]; + for (let i = 0; i < contracts.length; i++) { + let contract = contracts[i]; + + // test unauthorized call + try { + await contract.airdrop(TOKEN_ID, [accounts[0]], { + from: accounts[2], + }); + } catch (e) { + assert.ok( + e.hijackedStack.includes( + "VM Exception while processing transaction: revert" + ) + ); + } + + // test airdrop to zero address + try { + await contract.airdrop(TOKEN_ID, [ZERO_ADDRESS], { + from: this.owner, + }); + } catch (e) { + assert.equal(e.reason, "ERC1155: transfer to the zero address"); + } + + // test insufficient balance + let recipients = + i == 0 + ? [accounts[0], accounts[1], accounts[2]] + : [accounts[3]]; + try { + await contract.airdrop(TOKEN_ID, recipients, { + from: this.owner, + }); + } catch (e) { + assert.equal( + e.reason, + "ERC1155: insufficient balance for transfer" + ); + } + + // mint token to prepare for below tests + let correctAmount = i == 0 ? 10 : 1; + await contract.mint(TOKEN_ID, correctAmount, { from: this.owner }); + assert.equal( + await contract.balanceOf(contract.address, TOKEN_ID), + correctAmount + ); + + // test airdrop successfully + let beforeBalance = await contract.balanceOf( + contract.address, + TOKEN_ID + ); + await contract.airdrop(TOKEN_ID, recipients, { + from: this.owner, + }); + let afterBalance = await contract.balanceOf( + contract.address, + TOKEN_ID + ); + + // check contract token balance + assert.equal( + afterBalance.toNumber(), + beforeBalance.toNumber() - recipients.length * 1 // always airdrop 1 token per address + ); + + // check recipients token balance + for (let j = 0; j < recipients.length; j++) { + assert.equal( + await contract.balanceOf(recipients[j], TOKEN_ID), + 1 + ); + } + + // test already airdropped + try { + await contract.airdrop(TOKEN_ID, recipients, { + from: this.owner, + }); + } catch (e) { + assert.equal( + e.reason, + "FeralFileTokenAirdrop: already airdropped" + ); + } + } + }); + + it("test end airdrop", async () => { + let contracts = this.contracts[3]; + for (let i = 0; i < contracts.length; i++) { + let contract = contracts[i]; + + // test unauthorized call + try { + await contract.burnRemaining(TOKEN_ID, { + from: accounts[2], + }); + } catch (e) { + assert.equal(e.reason, "Ownable: caller is not the owner"); + } + + // test end airdrop successfully + await contract.end({ from: this.owner }); + assert.equal(await contract.isEnded(), true); + + // test already ended + try { + await contract.end({ from: this.owner }); + } catch (e) { + assert.equal(e.reason, "FeralFileTokenAirdrop: already ended"); + } + + // test mint after ended + try { + await contract.mint(TOKEN_ID, 1, { from: this.trustee }); + } catch (e) { + assert.equal(e.reason, "FeralFileTokenAirdrop: already ended"); + } + + // test airdrop after ended + try { + await contract.airdrop(TOKEN_ID, [accounts[0]], { + from: this.trustee, + }); + } catch (e) { + assert.equal(e.reason, "FeralFileTokenAirdrop: already ended"); + } + } + }); + + it("test burn remaining", async () => { + let contracts = this.contracts[4]; + for (let i = 0; i < contracts.length; i++) { + let contract = contracts[i]; + + // test unauthorized call + try { + await contract.burnRemaining(TOKEN_ID, { + from: accounts[2], + }); + } catch (e) { + assert.equal(e.reason, "Ownable: caller is not the owner"); + } + + // mint token to prepare for below tests + let correctAmount = i == 0 ? 10 : 1; + await contract.mint(TOKEN_ID, correctAmount, { + from: this.trustee, + }); + + // test burn remaining successfully + await contract.burnRemaining(TOKEN_ID, { from: this.owner }); + let afterBalance = await contract.balanceOf( + contract.address, + TOKEN_ID + ); + assert.equal(afterBalance.toNumber(), 0); + } + }); +}); diff --git a/truffle-config.js b/truffle-config.js index 2cba691..d5cbe72 100644 --- a/truffle-config.js +++ b/truffle-config.js @@ -18,115 +18,123 @@ * */ -const HDWalletProvider = require('@truffle/hdwallet-provider'); +const HDWalletProvider = require("@truffle/hdwallet-provider"); // -const fs = require('fs'); -let secret = JSON.parse(fs.readFileSync('.secret.json')); +const fs = require("fs"); +let secret = JSON.parse(fs.readFileSync(".secret.json")); let { - mnemonic, - mainnet_mnemonic, - mainnet_endpoint, - goerli_endpoint, - etherscan_api, + mnemonic, + mainnet_mnemonic, + mainnet_endpoint, + goerli_endpoint, + sepolia_endpoint, + etherscan_api, } = secret; module.exports = { - /** - * Networks define how you connect to your ethereum client and let you set the - * defaults web3 uses to send transactions. If you don't specify one truffle - * will spin up a development blockchain for you on port 9545 when you - * run `develop` or `test`. You can ask a truffle command to use a specific - * network from the command line, e.g - * - * $ truffle test --network - */ + /** + * Networks define how you connect to your ethereum client and let you set the + * defaults web3 uses to send transactions. If you don't specify one truffle + * will spin up a development blockchain for you on port 9545 when you + * run `develop` or `test`. You can ask a truffle command to use a specific + * network from the command line, e.g + * + * $ truffle test --network + */ - networks: { - // Useful for testing. The `development` name is special - truffle uses it by default - // if it's defined here and no other network is specified at the command line. - // You should run a client (like ganache-cli, geth or parity) in a separate terminal - // tab if you use this network and you must also set the `host`, `port` and `network_id` - // options below to some value. - // - mainnet: { - provider: function () { - return new HDWalletProvider(mainnet_mnemonic, mainnet_endpoint); - }, - gas: 3500000, - gasPrice: 90000000000, // 20 gwei (in wei) (default: 100 gwei) - network_id: 1, - }, - development: { - host: '127.0.0.1', // Localhost (default: none) - port: 7545, // Standard Ethereum port (default: none) - network_id: '*', // Any network (default: none) - }, - goerli: { - provider: function () { - return new HDWalletProvider(mnemonic, goerli_endpoint); - }, - // gas: 6000000, - network_id: 5, + networks: { + // Useful for testing. The `development` name is special - truffle uses it by default + // if it's defined here and no other network is specified at the command line. + // You should run a client (like ganache-cli, geth or parity) in a separate terminal + // tab if you use this network and you must also set the `host`, `port` and `network_id` + // options below to some value. + // + mainnet: { + provider: function () { + return new HDWalletProvider(mainnet_mnemonic, mainnet_endpoint); + }, + gas: 3500000, + gasPrice: 90000000000, // 20 gwei (in wei) (default: 100 gwei) + network_id: 1, + }, + development: { + host: "127.0.0.1", // Localhost (default: none) + port: 7545, // Standard Ethereum port (default: none) + network_id: "*", // Any network (default: none) + }, + goerli: { + provider: function () { + return new HDWalletProvider(mnemonic, goerli_endpoint); + }, + // gas: 6000000, + network_id: 5, + }, + sepolia: { + provider: function () { + return new HDWalletProvider(mnemonic, sepolia_endpoint); + }, + // gas: 6000000, + network_id: 11155111, + }, + // Another network with more advanced options... + // advanced: { + // port: 8777, // Custom port + // network_id: 1342, // Custom network + // gas: 8500000, // Gas sent with each transaction (default: ~6700000) + // gasPrice: 20000000000, // 20 gwei (in wei) (default: 100 gwei) + // from:
, // Account to send txs from (default: accounts[0]) + // websocket: true // Enable EventEmitter interface for web3 (default: false) + // }, + // Useful for deploying to a public network. + // NB: It's important to wrap the provider as a function. + // ropsten: { + // provider: () => new HDWalletProvider(mnemonic, `https://ropsten.infura.io/v3/YOUR-PROJECT-ID`), + // network_id: 3, // Ropsten's id + // gas: 5500000, // Ropsten has a lower block limit than mainnet + // confirmations: 2, // # of confs to wait between deployments. (default: 0) + // timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50) + // skipDryRun: true // Skip dry run before migrations? (default: false for public nets ) + // }, + // Useful for private networks + // private: { + // provider: () => new HDWalletProvider(mnemonic, `https://network.io`), + // network_id: 2111, // This network is yours, in the cloud. + // production: true // Treats this network as if it was a public net. (default: false) + // } }, - // Another network with more advanced options... - // advanced: { - // port: 8777, // Custom port - // network_id: 1342, // Custom network - // gas: 8500000, // Gas sent with each transaction (default: ~6700000) - // gasPrice: 20000000000, // 20 gwei (in wei) (default: 100 gwei) - // from:
, // Account to send txs from (default: accounts[0]) - // websocket: true // Enable EventEmitter interface for web3 (default: false) - // }, - // Useful for deploying to a public network. - // NB: It's important to wrap the provider as a function. - // ropsten: { - // provider: () => new HDWalletProvider(mnemonic, `https://ropsten.infura.io/v3/YOUR-PROJECT-ID`), - // network_id: 3, // Ropsten's id - // gas: 5500000, // Ropsten has a lower block limit than mainnet - // confirmations: 2, // # of confs to wait between deployments. (default: 0) - // timeoutBlocks: 200, // # of blocks before a deployment times out (minimum/default: 50) - // skipDryRun: true // Skip dry run before migrations? (default: false for public nets ) - // }, - // Useful for private networks - // private: { - // provider: () => new HDWalletProvider(mnemonic, `https://network.io`), - // network_id: 2111, // This network is yours, in the cloud. - // production: true // Treats this network as if it was a public net. (default: false) - // } - }, - // Set default mocha options here, use special reporters etc. - mocha: { - // timeout: 100000 - }, + // Set default mocha options here, use special reporters etc. + mocha: { + // timeout: 100000 + }, - // Configure your compilers - compilers: { - solc: { - version: '0.8.17', // Fetch exact version from solc-bin (default: truffle's version) - // docker: true, // Use "0.5.1" you've installed locally with docker (default: false) - settings: { - // See the solidity docs for advice about optimization and evmVersion - optimizer: { - enabled: true, - runs: 200, + // Configure your compilers + compilers: { + solc: { + version: "0.8.17", // Fetch exact version from solc-bin (default: truffle's version) + // docker: true, // Use "0.5.1" you've installed locally with docker (default: false) + settings: { + // See the solidity docs for advice about optimization and evmVersion + optimizer: { + enabled: true, + runs: 200, + }, + // evmVersion: "byzantium" + }, }, - // evmVersion: "byzantium" - }, }, - }, - // Truffle DB is currently disabled by default; to enable it, change enabled: false to enabled: true - // - // Note: if you migrated your contracts prior to enabling this field in your Truffle project and want - // those previously migrated contracts available in the .db directory, you will need to run the following: - // $ truffle migrate --reset --compile-all + // Truffle DB is currently disabled by default; to enable it, change enabled: false to enabled: true + // + // Note: if you migrated your contracts prior to enabling this field in your Truffle project and want + // those previously migrated contracts available in the .db directory, you will need to run the following: + // $ truffle migrate --reset --compile-all - db: { - enabled: false, - }, - plugins: ['truffle-plugin-verify', 'solidity-coverage'], - api_keys: { - etherscan: etherscan_api, - }, + db: { + enabled: false, + }, + plugins: ["truffle-plugin-verify", "solidity-coverage"], + api_keys: { + etherscan: etherscan_api, + }, };