diff --git a/contracts/ZeroDAOTokenV3.sol b/contracts/ZeroDAOTokenV3.sol new file mode 100644 index 0000000..4152b85 --- /dev/null +++ b/contracts/ZeroDAOTokenV3.sol @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.3; + +// Slight modifiations from base Open Zeppelin Contracts +// Consult /oz/README.md for more information +import "./oz/ERC20Upgradeable.sol"; +import "./oz/ERC20SnapshotUpgradeable.sol"; +import "./oz/ERC20PausableUpgradeable.sol"; + +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +contract ZeroDAOTokenV3 is + OwnableUpgradeable, + ERC20Upgradeable, + ERC20PausableUpgradeable, + ERC20SnapshotUpgradeable +{ + using SafeERC20 for IERC20; + + event AuthorizedSnapshotter(address account); + event DeauthorizedSnapshotter(address account); + event ERC20TokenWithdrawn( + IERC20 indexed token, + address indexed to, + uint256 indexed amount + ); + + // Mapping which stores all addresses allowed to snapshot + mapping(address => bool) authorizedToSnapshot; + + function initialize( + string memory name, + string memory symbol + ) public initializer { + __ERC20_init(name, symbol); + } + + // Call this on the implementation contract (not the proxy) + function initializeImplementation() public initializer {} + + /** + * Utility function to transfer tokens to many addresses at once. + * @param recipients The addresses to send tokens to + * @param amount The amount of tokens to send + * @return Boolean if the transfer was a success + */ + function transferBulk( + address[] calldata recipients, + uint256 amount + ) external returns (bool) { + address sender = _msgSender(); + + uint256 total = amount * recipients.length; + require( + _balances[sender] >= total, + "ERC20: transfer amount exceeds balance" + ); + + _balances[sender] -= total; + + for (uint256 i = 0; i < recipients.length; ++i) { + address recipient = recipients[i]; + require(recipient != address(0), "ERC20: transfer to the zero address"); + + // Note: _beforeTokenTransfer isn't called here + // This function emulates what it would do + + _balances[recipient] += amount; + + emit Transfer(sender, recipient, amount); + } + + return true; + } + + /** + * Utility function to transfer tokens to many addresses at once. + * @param sender The address to send the tokens from + * @param recipients The addresses to send tokens to + * @param amount The amount of tokens to send + * @return Boolean if the transfer was a success + */ + function transferFromBulk( + address sender, + address[] calldata recipients, + uint256 amount + ) external returns (bool) { + uint256 total = amount * recipients.length; + require( + _balances[sender] >= total, + "ERC20: transfer amount exceeds balance" + ); + + // Ensure enough allowance + uint256 currentAllowance = _allowances[sender][_msgSender()]; + require( + currentAllowance >= total, + "ERC20: transfer total exceeds allowance" + ); + _approve(sender, _msgSender(), currentAllowance - total); + + _balances[sender] -= total; + + for (uint256 i = 0; i < recipients.length; ++i) { + address recipient = recipients[i]; + require(recipient != address(0), "ERC20: transfer to the zero address"); + + // Note: _beforeTokenTransfer isn't called here + // This function emulates what it would do (paused and snapshot) + + _balances[recipient] += amount; + + emit Transfer(sender, recipient, amount); + } + + return true; + } + + function _beforeTokenTransfer( + address from, + address to, + uint256 amount + ) + internal + virtual + override( + ERC20PausableUpgradeable, + ERC20SnapshotUpgradeable, + ERC20Upgradeable + ) + { + super._beforeTokenTransfer(from, to, amount); + } + + function _transfer( + address from, + address to, + uint256 amount + ) internal override { + super._transfer(from, to, amount); + + if (to == address(this)) { + _burn(to, amount); + } + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index f4abbb1..f6155ed 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -35,12 +35,11 @@ const config: HardhatUserConfig = { settings: { optimizer: { enabled: true, - runs: 200 - } - } + runs: 200, + }, + }, }, - { version: "0.6.0", settings: {} } - + { version: "0.6.0", settings: {} }, ], }, paths: { @@ -87,11 +86,10 @@ const config: HardhatUserConfig = { chainId: 11155111, urls: { apiURL: "https://api-sepolia.etherscan.io/api", - browserURL: "https://sepolia.etherscan.io" - } - } - - ] + browserURL: "https://sepolia.etherscan.io", + }, + }, + ], }, }; export default config; diff --git a/scripts/deployWILD.ts b/scripts/deployWILD.ts index 1db4664..38e4b55 100644 --- a/scripts/deployWILD.ts +++ b/scripts/deployWILD.ts @@ -1,5 +1,12 @@ import * as hre from "hardhat"; -import { ERC20Mock, ERC20Mock__factory, ZeroDAOToken, ZeroDAOToken__factory, ZeroDAOTokenV2, ZeroDAOTokenV2__factory } from "../typechain"; +import { + ERC20Mock, + ERC20Mock__factory, + ZeroDAOToken, + ZeroDAOToken__factory, + ZeroDAOTokenV2, + ZeroDAOTokenV2__factory, +} from "../typechain"; import { getLogger } from "../utilities"; const logger = getLogger("scripts::deployWILD"); @@ -39,11 +46,14 @@ async function main() { "wilder-prod" ); - const wildToken: ZeroDAOToken = await deployWILDTx.deployed() as ZeroDAOToken; + const wildToken: ZeroDAOToken = (await deployWILDTx.deployed()) as ZeroDAOToken; logger.info(`Deployed WILD Token to: ${wildToken.address}`); const mockFactory = new ERC20Mock__factory(deployer); - const mockToken: ERC20Mock = await mockFactory.deploy("MOCK TOKEN", "MOCK") as ERC20Mock; + const mockToken: ERC20Mock = (await mockFactory.deploy( + "MOCK TOKEN", + "MOCK" + )) as ERC20Mock; logger.info(`Deployed MOCK Token to: ${mockToken.address}`); } diff --git a/scripts/upgrade/01-deploy-v1.ts b/scripts/upgrade/01-deploy-v1.ts index 76578cb..cf1eb8c 100644 --- a/scripts/upgrade/01-deploy-v1.ts +++ b/scripts/upgrade/01-deploy-v1.ts @@ -1,32 +1,40 @@ import * as hre from "hardhat"; import { deployFundTransfer } from "../../test/helpers/deploy-fund-transfer"; -import { DEFAULT_MOCK_TOKEN_AMOUNT, DEFAULT_MOCK_TOKEN_DECIMALS } from "../../test/helpers/constants"; +import { + DEFAULT_MOCK_TOKEN_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS, +} from "../../test/helpers/constants"; +import { getLogger } from "../../utilities"; /** * Script 01: Deploy V1 Token Contract - * + * * This script performs the initial setup for testing the WILD token upgrade process. * It serves as the first step in a multi-step upgrade testing workflow. - * + * * Operations performed: * - Deploy the ZeroDAOToken (V1) as an upgradeable proxy * - Deploy an ERC20Mock token for testing purposes * - Mint mock tokens to the ZeroDAOToken contract * - Transfer ownership of both the Proxy and ProxyAdmin to a specified address - * + * * Environment Variables Required: * - OWNER_ADDRESS: The address to transfer ownership to (optional for hardhat network) - * + * * Output: * - Creates a JSON file with deployment details: `01-deploy-v1-${network}.json` */ const main = async () => { + const logger = getLogger("01-deploy-v1"); + + logger.info("Executing..."); + const [deployer] = await hre.ethers.getSigners(); const outputFile = `01-deploy-v1-${hre.network.name}.json`; // The address of the new owner to be transferred to for WILD as well as ProxyAdmin // If not using hardhat, this should be a Safe address - let newOwnerAddress = process.env.OWNER_ADDRESS; + const newOwnerAddress = process.env.OWNER_ADDRESS; if (!newOwnerAddress) { throw new Error("No address given for transfer ownership call"); @@ -37,14 +45,10 @@ const main = async () => { DEFAULT_MOCK_TOKEN_DECIMALS ); + logger.info(`Deploying ZeroDAOToken to ${hre.network.name}`); + // Deploy V1 token contract with mock token for testing - await deployFundTransfer( - deployer, - newOwnerAddress, - outputFile, - amount, - true - ); + await deployFundTransfer(deployer, newOwnerAddress, outputFile, amount, true); }; main() diff --git a/scripts/upgrade/02-deploy-v2.ts b/scripts/upgrade/02-deploy-v2.ts index fe082e4..c061d83 100644 --- a/scripts/upgrade/02-deploy-v2.ts +++ b/scripts/upgrade/02-deploy-v2.ts @@ -1,41 +1,49 @@ import * as hre from "hardhat"; import { deployV2 } from "../../test/helpers/deploy-v2"; +import { ZeroDAOTokenV2, ZeroDAOTokenV2__factory } from "../../typechain"; +import { getLogger } from "../../utilities"; +import { initImpl } from "../../test/helpers/init-impl"; /** * Script 02: Deploy V2 Implementation Contract - * + * * This script deploys the ZeroDAOTokenV2 implementation contract that will be used * for upgrading the existing V1 proxy contract. This is step 2 in the upgrade process. - * + * * Note: This only deploys the implementation contract, not a proxy. The actual upgrade * happens separately using the proxy upgrade mechanism. - * + * * Operations performed: * - Deploy ZeroDAOTokenV2 implementation contract * - Save deployment details to output file - * + * * Prerequisites: * - V1 contract should already be deployed (from script 01) * - Deployer account should have sufficient funds for deployment - * + * * Output: * - Creates a JSON file with implementation address: `02-deploy-v2-${network}.json` - * + * * Production Notes: * - Ensure proper addresses are configured * - Deployer account needs funds for deployment * - For mainnet, proposer should be configured on Safe multisig */ const main = async () => { + const logger = getLogger("02-deploy-v2"); + + logger.info("Executing..."); + const [deployer] = await hre.ethers.getSigners(); const outputFile = `02-deploy-v2-${hre.network.name}.json`; + logger.info(`Deploying ZeroDAOTokenV2 to ${hre.network.name}`); + // Deploy V2 implementation contract - await deployV2( - deployer, - outputFile, - true - ); + const contract: ZeroDAOTokenV2 = await deployV2(deployer, outputFile, true); + + // Calling to initalize the implementation contract + await initImpl(new ZeroDAOTokenV2__factory(deployer), contract.address); }; main() diff --git a/scripts/upgrade/03-read-state.ts b/scripts/upgrade/03-read-state.ts index b1df0c1..834a44b 100644 --- a/scripts/upgrade/03-read-state.ts +++ b/scripts/upgrade/03-read-state.ts @@ -4,48 +4,43 @@ import { ZeroDAOToken__factory } from "../../typechain"; /** * Script 03: Read Contract State (Pre-Upgrade) - * + * * This script reads and captures the current state of the ZeroDAOToken contract * before performing an upgrade. This creates a baseline snapshot that can be * compared against the post-upgrade state to ensure data integrity. - * + * * Purpose: * - Capture contract storage state before upgrade * - Create a reference point for state comparison * - Ensure upgrade doesn't corrupt existing data - * + * * Usage Pattern: * 1. Run this script BEFORE upgrade (captures pre-upgrade state) * 2. Perform the contract upgrade * 3. Run script 04 to compare pre/post upgrade states - * + * * Environment Variables Required: * - TOKEN_ADDRESS: Address of the deployed ZeroDAOToken contract - * + * * Output: * - Creates a JSON file with contract state: `03-read-state-${network}.json` - * + * * @note This script should be executed twice in the upgrade workflow: * once BEFORE upgrading and once AFTER upgrading (via script 04) */ const main = async () => { const [creator] = await hre.ethers.getSigners(); - const outputFile = `03-read-state-${hre.network.name}.json`; + const outputFile = `03-read-state-v1-to-v2-${hre.network.name}.json`; // Get the token address from initial deployment in step 1 - let tokenAddress = process.env.TOKEN_ADDRESS; + const tokenAddress = process.env.TOKEN_ADDRESS; if (!tokenAddress) { throw Error("No token address present in env"); } // Read and save current contract state - await readState( - creator, - tokenAddress, - outputFile, - false - ); -} + await readState(creator, tokenAddress, outputFile, false); +}; main() .then(() => { diff --git a/scripts/upgrade/04-read-and-compare-state.ts b/scripts/upgrade/04-read-and-compare-state.ts index 44c22f8..1de5f72 100644 --- a/scripts/upgrade/04-read-and-compare-state.ts +++ b/scripts/upgrade/04-read-and-compare-state.ts @@ -4,33 +4,33 @@ import { readCompareState } from "../../test/helpers/read-compare-state"; /** * Script 04: Read and Compare Contract State (Post-Upgrade) - * + * * This script reads the current contract state after an upgrade and compares it * with the pre-upgrade state captured by script 03. This ensures that the upgrade * process preserved all existing data and didn't introduce any storage corruption. - * + * * Purpose: * - Read current (post-upgrade) contract state * - Compare with pre-upgrade state from script 03 * - Validate that upgrade preserved data integrity * - Generate comparison report - * + * * Workflow Position: * 1. Script 03: Capture pre-upgrade state * 2. Perform contract upgrade * 3. THIS SCRIPT: Compare post-upgrade state with pre-upgrade baseline - * + * * Environment Variables Required: * - TOKEN_ADDRESS: Address of the upgraded ZeroDAOToken contract - * + * * Prerequisites: * - Pre-upgrade state file must exist (created by script 03) * - Contract upgrade must have been completed - * + * * Output: * - Creates a JSON file with comparison results: `04-read-and-compare-state-${network}.json` * - Throws error if state differences are detected - * + * * @throws Error if pre-upgrade state file is not found * @throws Error if storage comparison fails (indicates upgrade corruption) */ @@ -39,16 +39,20 @@ const main = async () => { const outputFile = `04-read-and-compare-state-${hre.network.name}.json`; // Find the pre-upgrade state file created by script 03 - const files = fs.readdirSync('.'); - const preUpgradeFile = files.find(file => file.startsWith('03-read-state-') && file.endsWith('.json')); + const files = fs.readdirSync("."); + const preUpgradeFile = files.find( + (file) => file.startsWith("03-read-state-") && file.endsWith(".json") + ); if (!preUpgradeFile) { - throw new Error('Pre-upgrade state file not found. Expected file starting with "03-read-state-". Please run script 03 first.'); + throw new Error( + 'Pre-upgrade state file not found. Expected file starting with "03-read-state-". Please run script 03 first.' + ); } - const preUpgradeState = JSON.parse(fs.readFileSync(preUpgradeFile, 'utf8')); + const preUpgradeState = JSON.parse(fs.readFileSync(preUpgradeFile, "utf8")); - let tokenAddress = process.env.TOKEN_ADDRESS; + const tokenAddress = process.env.TOKEN_ADDRESS; if (!tokenAddress) { throw Error("No token address present in env"); } @@ -61,7 +65,7 @@ const main = async () => { outputFile, true ); -} +}; main() .then(() => { diff --git a/scripts/upgrade/05-deploy-v3.ts b/scripts/upgrade/05-deploy-v3.ts new file mode 100644 index 0000000..4f2e970 --- /dev/null +++ b/scripts/upgrade/05-deploy-v3.ts @@ -0,0 +1,53 @@ +import * as hre from "hardhat"; +import { ZeroDAOTokenV3 } from "../../typechain"; +import { getLogger } from "../../utilities"; +import { deployV3 } from "../../test/helpers/deploy-v3"; + +/** + * Script 02: Deploy V2 Implementation Contract + * + * This script deploys the ZeroDAOTokenV2 implementation contract that will be used + * for upgrading the existing V1 proxy contract. This is step 2 in the upgrade process. + * + * Note: This only deploys the implementation contract, not a proxy. The actual upgrade + * happens separately using the proxy upgrade mechanism. + * + * Operations performed: + * - Deploy ZeroDAOTokenV2 implementation contract + * - Save deployment details to output file + * + * Prerequisites: + * - V1 contract should already be deployed (from script 01) + * - Deployer account should have sufficient funds for deployment + * + * Output: + * - Creates a JSON file with implementation address: `02-deploy-v2-${network}.json` + * + * Production Notes: + * - Ensure proper addresses are configured + * - Deployer account needs funds for deployment + * - For mainnet, proposer should be configured on Safe multisig + */ +const main = async () => { + const logger = getLogger("02-deploy-v2"); + + logger.info("Executing..."); + + const [deployer] = await hre.ethers.getSigners(); + const outputFile = `05-deploy-v2-${hre.network.name}.json`; + + logger.info(`Deploying ZeroDAOTokenV2 to ${hre.network.name}`); + + // Deploy V2 implementation contract + const contract: ZeroDAOTokenV3 = await deployV3(deployer, outputFile, true); + + logger.info(`Successfully deploy ZeroDAOTokenV3 at ${contract.address}`); +}; + +main() + .then(() => { + process.exit(0); + }) + .catch((error) => { + throw error; + }); diff --git a/scripts/utils/storage-check.ts b/scripts/utils/storage-check.ts index 08894b8..907feb3 100644 --- a/scripts/utils/storage-check.ts +++ b/scripts/utils/storage-check.ts @@ -1,10 +1,18 @@ -import { getStorageLayout, getUnlinkedBytecode, getVersion, StorageLayout } from '@openzeppelin/upgrades-core'; +import { + getStorageLayout, + getUnlinkedBytecode, + getVersion, + StorageLayout, +} from "@openzeppelin/upgrades-core"; import { readValidations } from "@openzeppelin/hardhat-upgrades/dist/validations"; import { BigNumber, Contract, ContractFactory } from "ethers"; import * as hre from "hardhat"; - -export type ContractStorageElement = string | number | BigNumber | Array; +export type ContractStorageElement = + | string + | number + | BigNumber + | Array; export type ContractStorageData = Array<{ [label: string]: ContractStorageElement; }>; @@ -14,12 +22,14 @@ export type ContractStorageDiff = Array<{ valueAfter: ContractStorageElement; }>; - export const getContractStorageLayout = async ( contractFactory: ContractFactory ): Promise => { const validations = await readValidations(hre); - const unlinkedBytecode = getUnlinkedBytecode(validations, contractFactory.bytecode); + const unlinkedBytecode = getUnlinkedBytecode( + validations, + contractFactory.bytecode + ); const version = getVersion(unlinkedBytecode, contractFactory.bytecode); return getStorageLayout(validations, version); @@ -38,17 +48,15 @@ export const readContractStorage = async ( ): Promise => { const newAcc = await acc; - if (type.includes("mapping") || type.includes("array")) - return newAcc; // Skip mappings and arrays + if (type.includes("mapping") || type.includes("array")) return newAcc; // Skip mappings and arrays try { const newLabel = label.startsWith("_") ? label.slice(1) : label; - const value = await contractObj[(newLabel as keyof Contract)](); + const value = await contractObj[newLabel as keyof Contract](); newAcc.push({ [label]: value }); } catch (e: unknown) { - if ((e as Error).message.includes("is not a function")) - return newAcc; // Skip non-public variables + if ((e as Error).message.includes("is not a function")) return newAcc; // Skip non-public variables console.log(`Error on LABEL ${label}: ${(e as Error).message}`); } @@ -59,10 +67,9 @@ export const readContractStorage = async ( ); }; - export const compareStorageData = ( dataBefore: ContractStorageData, - dataAfter: ContractStorageData, + dataAfter: ContractStorageData ) => { const storageDiff = dataAfter.reduce( (acc: ContractStorageDiff | undefined, stateVar, idx) => { @@ -71,7 +78,8 @@ export const compareStorageData = ( if (!dataBefore[idx]) return acc; const equals = BigNumber.isBigNumber(value) - ? (value as BigNumber).eq((dataBefore[idx][key] as BigNumber)) : value === dataBefore[idx][key]; + ? (value as BigNumber).eq(dataBefore[idx][key] as BigNumber) + : value === dataBefore[idx][key]; if (!equals) { console.error( @@ -79,7 +87,7 @@ export const compareStorageData = ( ); return [ - ...acc as ContractStorageDiff, + ...(acc as ContractStorageDiff), { key, valueBefore: dataBefore[idx][key], @@ -89,7 +97,8 @@ export const compareStorageData = ( } else { return acc; } - }, [] + }, + [] ); if (storageDiff && storageDiff.length > 0) { diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts index ffb8fd2..3bb6d3e 100644 --- a/test/helpers/constants.ts +++ b/test/helpers/constants.ts @@ -1,3 +1,8 @@ +// Proxy Implementation Storage Slot bytes +// This value is the same for all @OpenZeppelin AdminUpgradeabilityProxy's +export const IMPL_STORAGE_SLOT = + "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + // ZeroDAOToken Constants export const DEFAULT_ZERO_TOKEN_NAME = "Wilder World Test"; export const DEFAULT_ZERO_TOKEN_SYMBOL = "tWILD"; @@ -16,20 +21,52 @@ export const DEFAULT_WITHDRAW_AMOUNT = "50000"; // 50,000 with 6 decimals export const DEFAULT_TOKEN_MINT_AMOUNT = "1000"; // 1,000 tokens (18 decimals) export const DEFAULT_TRANSFER_AMOUNT = "100"; // 100 tokens (18 decimals) +// V2 Test Constants +export const DEFAULT_V2_TOKEN_NAME = "Test DAO Token V2"; +export const DEFAULT_V2_TOKEN_SYMBOL = "TDT2"; +export const DEFAULT_V2_ADDITIONAL_AMOUNT = "100000"; // 100,000 with 6 decimals +export const DEFAULT_V2_TEST_AMOUNT = "1000"; // 1,000 with 6 decimals +export const DEFAULT_V2_INSUFFICIENT_AMOUNT = "2000000"; // 2,000,000 with 6 decimals +export const DEFAULT_V2_MINT_AMOUNT = "1000"; // 1,000 tokens (18 decimals) +export const DEFAULT_V2_BURN_AMOUNT = "100"; // 100 tokens (18 decimals) +export const DEFAULT_V2_PAUSE_TRANSFER_AMOUNT = "1"; // 1 token (18 decimals) +export const DEFAULT_V2_BULK_TRANSFER_AMOUNT = "10"; // 10 tokens (18 decimals) +export const DEFAULT_V2_BULK_TRANSFERFROM_AMOUNT = "5"; // 5 tokens (18 decimals) + +// V3 Test Constants +export const DEFAULT_V3_TOKEN_NAME = "Test DAO Token V3"; +export const DEFAULT_V3_TOKEN_SYMBOL = "TDT3"; +export const DEFAULT_V3_DECIMALS = 18; +export const DEFAULT_V3_INITIAL_MINT_AMOUNT = "10000"; // 10,000 tokens (18 decimals) +export const DEFAULT_V3_INITIAL_SUPPLY = "1000000"; // 1,000,000 tokens (18 decimals) +export const DEFAULT_V3_ABI_TEST_AMOUNT = "1000"; // 1,000 tokens (18 decimals) +export const DEFAULT_V3_ABI_BURN_AMOUNT = "100"; // 100 tokens (18 decimals) +export const DEFAULT_V3_BURN_TRANSFER_AMOUNT = "100"; // 100 tokens (18 decimals) +export const DEFAULT_V3_NORMAL_TRANSFER_AMOUNT = "10"; // 10 tokens (18 decimals) +export const DEFAULT_V3_BULK_TRANSFER_AMOUNT = "5"; // 5 tokens (18 decimals) +export const DEFAULT_V3_BULK_TRANSFERFROM_AMOUNT = "2"; // 2 tokens (18 decimals) + // Network Constants export const HARDHAT_NETWORK_NAME = "hardhat"; // Logger Names export const DEPLOY_FUND_TRANSFER_LOGGER = "deploy-fund-transfer"; export const DEPLOY_V2_LOGGER = "deploy-v2"; -export const READ_STATE_LOGGER = "read-state" +export const DEPLOY_V3_LOGGER = "deploy-v3"; +export const READ_STATE_LOGGER = "read-state"; export const READ_AND_COMPARE_LOGGER = "read-compare-state"; // Messages -export const TRANSFERRING_OWNERSHIP_MESSAGE = "Transferring ownership of proxy..."; -export const OWNERSHIP_TRANSFERRED_MESSAGE = "Ownership transferred successfully"; +export const TRANSFERRING_OWNERSHIP_MESSAGE = + "Transferring ownership of proxy..."; +export const OWNERSHIP_TRANSFERRED_MESSAGE = + "Ownership transferred successfully"; export const DEPLOYING_V2_MESSAGE = "Deploying ZeroDAOTokenV2 implementation"; -export const READING_POST_UPGRADE_STATE_MESSAGE = "Reading post upgrade state..."; -export const COMPARING_STATES_MESSAGE = "Comparing pre and post upgrade states..."; -export const STORAGE_COMPARISON_PASSED_MESSAGE = "Storage comparison passed - no differences found"; +export const DEPLOYING_V3_MESSAGE = "Deploying ZeroDAOTokenV3 implementation"; +export const READING_POST_UPGRADE_STATE_MESSAGE = + "Reading post upgrade state..."; +export const COMPARING_STATES_MESSAGE = + "Comparing pre and post upgrade states..."; +export const STORAGE_COMPARISON_PASSED_MESSAGE = + "Storage comparison passed - no differences found"; export const STORAGE_COMPARISON_FAILED_MESSAGE = "Storage comparison failed:"; diff --git a/test/helpers/deploy-fund-transfer.ts b/test/helpers/deploy-fund-transfer.ts index 5d521a5..8970a50 100644 --- a/test/helpers/deploy-fund-transfer.ts +++ b/test/helpers/deploy-fund-transfer.ts @@ -1,9 +1,13 @@ import * as hre from "hardhat"; -import * as fs from "fs"; +import * as fs from "fs"; import { assert } from "console"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { getLogger } from "../../utilities"; -import { ZeroDAOToken, ZeroDAOToken__factory, ERC20Mock__factory } from "../../typechain"; +import { + ZeroDAOToken, + ZeroDAOToken__factory, + ERC20Mock__factory, +} from "../../typechain"; import { DEFAULT_ZERO_TOKEN_NAME, DEFAULT_ZERO_TOKEN_SYMBOL, @@ -14,23 +18,23 @@ import { HARDHAT_NETWORK_NAME, DEPLOY_FUND_TRANSFER_LOGGER, TRANSFERRING_OWNERSHIP_MESSAGE, - OWNERSHIP_TRANSFERRED_MESSAGE + OWNERSHIP_TRANSFERRED_MESSAGE, } from "./constants"; import { BigNumber } from "ethers"; /** * Deploy V1 Token Contract with Mock Token Setup - * + * * This function handles the complete deployment and setup of the V1 ZeroDAOToken * contract along with a mock ERC20 token for testing purposes. It's designed to * create a complete testing environment for the upgrade process. - * + * * @param creator - The signer account that will deploy the contracts * @param outputFile - Optional path to save deployment details as JSON * @param amount - Optional amount of mock tokens to mint (defaults to 500,000 with 6 decimals) - * + * * @returns Promise - The deployed ZeroDAOToken contract instance - * + * * Operations performed: * 1. Deploy ZeroDAOToken V1 as an upgradeable proxy * 2. Transfer ownership of the token contract to specified address @@ -38,10 +42,10 @@ import { BigNumber } from "ethers"; * 4. Deploy a mock ERC20 token for testing * 5. Mint mock tokens to the ZeroDAOToken contract * 6. Save deployment details to output file (if specified) - * + * * Environment Variables: * - OWNER_ADDRESS: Address to transfer ownership to (optional for hardhat) - * + * * @throws Error if OWNER_ADDRESS is not set for non-hardhat networks * @throws AssertionError if token minting fails */ @@ -50,7 +54,7 @@ export const deployFundTransfer = async ( newOwnerAddress: string, outputFile?: string, amount?: number | BigNumber, - verbose: boolean = false + verbose = false ): Promise => { const logger = getLogger(DEPLOY_FUND_TRANSFER_LOGGER); logger.state.isEnabled = verbose; @@ -65,13 +69,10 @@ export const deployFundTransfer = async ( // Deploy ZeroDaoTokenV1 and transfer ownership const factory = new ZeroDAOToken__factory(creator); - const zeroDAOTokenV1 = await hre.upgrades.deployProxy( - factory, - [ - name, - symbol - ], - ) as ZeroDAOToken; + const zeroDAOTokenV1 = (await hre.upgrades.deployProxy(factory, [ + name, + symbol, + ])) as ZeroDAOToken; if (hre.network.name !== HARDHAT_NETWORK_NAME) { await zeroDAOTokenV1.deployed(); @@ -108,8 +109,17 @@ export const deployFundTransfer = async ( logger.info(`${mockName} deployed to address: ${mockToken.address}`); - const amountToUse = amount ? amount : hre.ethers.utils.parseUnits(DEFAULT_MOCK_TOKEN_AMOUNT, DEFAULT_MOCK_TOKEN_DECIMALS); - logger.info(`Minting ${amountToUse.toString()} of ${mockSymbol} to ${zeroDAOTokenV1.address}`); + const amountToUse = amount + ? amount + : hre.ethers.utils.parseUnits( + DEFAULT_MOCK_TOKEN_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ); + logger.info( + `Minting ${amountToUse.toString()} of ${mockSymbol} to ${ + zeroDAOTokenV1.address + }` + ); const balanceBefore = await mockToken.balanceOf(zeroDAOTokenV1.address); logger.info(`Balance before minting: ${balanceBefore.toString()}`); @@ -121,9 +131,16 @@ export const deployFundTransfer = async ( logger.info(`Balance after minting: ${balanceAfter.toString()}`); // Confirm balance has changed correctly - assert(balanceAfter.eq(balanceBefore.add(amountToUse)), "Balance minting failed"); + assert( + balanceAfter.eq(balanceBefore.add(amountToUse)), + "Balance minting failed" + ); - logger.info(`Minting successful: ${amountToUse.toString()} of ${mockSymbol} to ${zeroDAOTokenV1.address}`); + logger.info( + `Minting successful: ${amountToUse.toString()} of ${mockSymbol} to ${ + zeroDAOTokenV1.address + }` + ); // Write to file if path specified if (outputFile) { @@ -137,7 +154,7 @@ export const deployFundTransfer = async ( proxyAdminOwner: await proxyAdmin.owner(), // Should be the same as newOwnerAddress mockToken: mockToken.address, mockTokenAmount: amountToUse.toString(), - deployedAt: new Date().toISOString() + deployedAt: new Date().toISOString(), }; fs.writeFileSync(outputFile, JSON.stringify(obj, undefined, 2)); @@ -145,4 +162,4 @@ export const deployFundTransfer = async ( } return zeroDAOTokenV1; -} +}; diff --git a/test/helpers/deploy-v2.ts b/test/helpers/deploy-v2.ts index 524ecde..b4718ab 100644 --- a/test/helpers/deploy-v2.ts +++ b/test/helpers/deploy-v2.ts @@ -1,42 +1,41 @@ import * as hre from "hardhat"; -import * as fs from "fs"; +import * as fs from "fs"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { getLogger } from "../../utilities"; import { ZeroDAOTokenV2__factory, ZeroDAOTokenV2 } from "../../typechain"; import { HARDHAT_NETWORK_NAME, DEPLOY_V2_LOGGER, - DEPLOYING_V2_MESSAGE + DEPLOYING_V2_MESSAGE, } from "./constants"; /** * Deploy V2 Implementation Contract - * + * * This function deploys the ZeroDAOTokenV2 implementation contract that will be used * for upgrading existing V1 proxy contracts. This deployment creates only the * implementation contract, not a proxy - the actual upgrade happens separately. - * + * * @param deployer - The signer account that will deploy the implementation contract * @param outputFile - Optional path to save deployment details as JSON - * + * * @returns Promise - The deployed ZeroDAOTokenV2 implementation contract - * + * * Operations performed: * 1. Deploy ZeroDAOTokenV2 implementation contract (not as proxy) * 2. Wait for deployment confirmation on non-hardhat networks * 3. Save deployment details to output file (if specified) - * + * * Usage Notes: * - This creates the implementation that can be used with OpenZeppelin's upgradeProxy * - The implementation address from this deployment is used in upgrade transactions - * - Multiple proxies can be upgraded to use the same implementation - * + * * @throws Error if deployment fails */ export const deployV2 = async ( deployer: SignerWithAddress, outputFile?: string, - verbose: boolean = false + verbose = false ): Promise => { const logger = getLogger(DEPLOY_V2_LOGGER); logger.state.isEnabled = verbose; @@ -47,20 +46,22 @@ export const deployV2 = async ( // Deploy ZeroDAOTokenV2 implementation (not as proxy) const tokenFactory = new ZeroDAOTokenV2__factory(deployer); - const zeroDAOTokenV2 = await tokenFactory.deploy() as ZeroDAOTokenV2; + const zeroDAOTokenV2 = (await tokenFactory.deploy()) as ZeroDAOTokenV2; if (hre.network.name !== HARDHAT_NETWORK_NAME) { await zeroDAOTokenV2.deployed(); } - logger.info(`ZeroDAOTokenV2 implementation deployed to address: ${zeroDAOTokenV2.address}`); + logger.info( + `ZeroDAOTokenV2 implementation deployed to address: ${zeroDAOTokenV2.address}` + ); // Write to file if path is given if (outputFile) { const obj = { network: hre.network.name, zeroDAOTokenV2Implementation: zeroDAOTokenV2.address, - deployedAt: new Date().toISOString() + deployedAt: new Date().toISOString(), }; fs.writeFileSync(outputFile, JSON.stringify(obj, undefined, 2)); @@ -68,4 +69,4 @@ export const deployV2 = async ( } return zeroDAOTokenV2; -} +}; diff --git a/test/helpers/deploy-v3.ts b/test/helpers/deploy-v3.ts new file mode 100644 index 0000000..e76b74d --- /dev/null +++ b/test/helpers/deploy-v3.ts @@ -0,0 +1,72 @@ +import * as hre from "hardhat"; +import * as fs from "fs"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { getLogger } from "../../utilities"; +import { ZeroDAOTokenV3__factory, ZeroDAOTokenV3 } from "../../typechain"; +import { + HARDHAT_NETWORK_NAME, + DEPLOYING_V3_MESSAGE, + DEPLOY_V3_LOGGER, +} from "./constants"; + +/** + * Deploy V3 Implementation Contract + * + * This function deploys the ZeroDAOTokenV3 implementation contract that will be used + * for upgrading existing V2 proxy contract. This deployment creates only the + * implementation contract, not a proxy - the actual upgrade happens separately. + * + * @param deployer - The signer account that will deploy the implementation contract + * @param outputFile - Optional path to save deployment details as JSON + * + * @returns Promise - The deployed ZeroDAOTokenV3 implementation contract + * + * Operations performed: + * 1. Deploy ZeroDAOTokenV3 implementation contract (not as proxy) + * 2. Wait for deployment confirmation on non-hardhat networks + * 3. Save deployment details to output file (if specified) + * + * Usage Notes: + * - This creates the implementation that can be used with OpenZeppelin's upgradeProxy + * - The implementation address from this deployment is used in upgrade transactions + * + * @throws Error if deployment fails + */ +export const deployV3 = async ( + deployer: SignerWithAddress, + outputFile?: string, + verbose = false +): Promise => { + const logger = getLogger(DEPLOY_V3_LOGGER); + logger.state.isEnabled = verbose; + + logger.info(`Network: ${hre.network.name}`); + logger.info(DEPLOYING_V3_MESSAGE); + logger.info(`Deployer: ${deployer.address}`); + + // Deploy ZeroDAOTokenV3 implementation (not as proxy) + const tokenFactory = new ZeroDAOTokenV3__factory(deployer); + const zeroDAOTokenV3 = (await tokenFactory.deploy()) as ZeroDAOTokenV3; + + if (hre.network.name !== HARDHAT_NETWORK_NAME) { + await zeroDAOTokenV3.deployed(); + } + + logger.info( + `ZeroDAOTokenV3 implementation deployed to address: ${zeroDAOTokenV3.address}` + ); + + // Write to file if path is given + if (outputFile) { + const obj = { + network: hre.network.name, + zeroDAOTokenV3Implementation: zeroDAOTokenV3.address, + deployedAt: new Date().toISOString(), + }; + + fs.writeFileSync(outputFile, JSON.stringify(obj, undefined, 2)); + logger.info(`Deployment data saved to: ${outputFile}`); + } + + return zeroDAOTokenV3; +}; diff --git a/test/helpers/init-impl.ts b/test/helpers/init-impl.ts new file mode 100644 index 0000000..4c58140 --- /dev/null +++ b/test/helpers/init-impl.ts @@ -0,0 +1,12 @@ +import { ContractFactory } from "ethers"; + +export const initImpl = async ( + contractFactory: ContractFactory, + implAddress: string +): Promise => { + const impl = contractFactory.attach(implAddress); + + await impl.initializeImplementation(); + + return impl.owner(); +}; diff --git a/test/helpers/read-compare-state.ts b/test/helpers/read-compare-state.ts index 6edc318..822d19e 100644 --- a/test/helpers/read-compare-state.ts +++ b/test/helpers/read-compare-state.ts @@ -1,6 +1,10 @@ -import * as fs from "fs"; +import * as fs from "fs"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; -import { compareStorageData, ContractStorageData, readContractStorage } from "../../scripts/utils/storage-check"; +import { + compareStorageData, + ContractStorageData, + readContractStorage, +} from "../../scripts/utils/storage-check"; import { readState } from "./read-state"; import { getLogger } from "../../utilities"; import { @@ -8,29 +12,29 @@ import { READING_POST_UPGRADE_STATE_MESSAGE, COMPARING_STATES_MESSAGE, STORAGE_COMPARISON_PASSED_MESSAGE, - STORAGE_COMPARISON_FAILED_MESSAGE + STORAGE_COMPARISON_FAILED_MESSAGE, } from "./constants"; /** * Read Current Contract State and Compare with Previous State - * + * * This function reads the current contract state and compares it with a previously * captured state to ensure that contract upgrades haven't corrupted existing data. * It's a critical validation step in the upgrade process. - * + * * @param deployer - The signer account used to interact with the contract * @param priorState - Previously captured contract state data to compare against * @param outputFile - Optional path to save the current state data as JSON - * + * * Operations performed: * 1. Read current contract state using readState function * 2. Save current state to output file (if specified) * 3. Compare current state with prior state using compareStorageData * 4. Log success or throw error if differences are found - * + * * Environment Variables Required: * - TOKEN_ADDRESS: Address of the contract to read state from - * + * * @throws Error if storage comparison fails (indicates data corruption) * @throws Error if TOKEN_ADDRESS is not set */ @@ -39,19 +43,14 @@ export const readCompareState = async ( tokenAddress: string, priorState: ContractStorageData, outputFile?: string, - verbose: boolean = false + verbose = false ) => { const logger = getLogger(READ_AND_COMPARE_LOGGER); logger.state.isEnabled = verbose; - logger.info(READING_POST_UPGRADE_STATE_MESSAGE); - const state = await readState( - deployer, - tokenAddress, - outputFile, - ); + const state = await readState(deployer, tokenAddress, outputFile); logger.info(COMPARING_STATES_MESSAGE); @@ -62,4 +61,4 @@ export const readCompareState = async ( logger.error(STORAGE_COMPARISON_FAILED_MESSAGE, error); throw error; } -} +}; diff --git a/test/helpers/read-state.ts b/test/helpers/read-state.ts index 668bf9f..db45828 100644 --- a/test/helpers/read-state.ts +++ b/test/helpers/read-state.ts @@ -1,45 +1,48 @@ import * as hre from "hardhat"; -import * as fs from "fs"; +import * as fs from "fs"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { ZeroDAOToken__factory } from "../../typechain"; -import { ContractStorageData, readContractStorage } from "../../scripts/utils/storage-check"; +import { + ContractStorageData, + readContractStorage, +} from "../../scripts/utils/storage-check"; import { Contract } from "ethers"; import { getLogger } from "../../utilities"; import { READ_STATE_LOGGER } from "./constants"; /** * Read Contract Storage State - * + * * This function reads the complete storage state of a ZeroDAOToken contract. * It's used to capture snapshots of contract state before and after upgrades * to ensure data integrity is maintained throughout the upgrade process. - * + * * @param deployer - The signer account used to interact with the contract * @param outputFile - Optional path to save the state data as JSON - * + * * @returns Promise - The complete contract storage state - * + * * Operations performed: * 1. Get contract address from TOKEN_ADDRESS environment variable * 2. Create contract instance using ZeroDAOToken factory * 3. Read complete contract storage using readContractStorage utility * 4. Save state to output file (if specified) - * + * * Environment Variables Required: * - TOKEN_ADDRESS: Address of the ZeroDAOToken contract to read - * + * * Usage: * - Called before upgrades to capture baseline state * - Called after upgrades to verify state preservation * - Used by comparison functions to validate upgrade integrity - * + * * @throws Error if TOKEN_ADDRESS is not set for non-hardhat networks */ export const readState = async ( deployer: SignerWithAddress, tokenAddress: string, outputFile?: string, - verbose: boolean = false + verbose = false ): Promise => { const logger = getLogger(READ_STATE_LOGGER); @@ -50,14 +53,11 @@ export const readState = async ( logger.info(`Reading storage state for contract: ${token.address}`); - const state = await readContractStorage( - tokenFactory, - token - ); + const state = await readContractStorage(tokenFactory, token); if (outputFile) { fs.writeFileSync(outputFile, JSON.stringify(state, undefined, 2)); } return state; -} +}; diff --git a/test/zDAOTokenUpgrade.test.ts b/test/zDAOTokenUpgrade.test.ts index da58264..2870fb5 100644 --- a/test/zDAOTokenUpgrade.test.ts +++ b/test/zDAOTokenUpgrade.test.ts @@ -6,13 +6,15 @@ import { ZeroDAOToken, ZeroDAOTokenV2, ZeroDAOTokenV2__factory, + ZeroDAOTokenV3, + ZeroDAOTokenV3__factory, ERC20Mock__factory, ERC20Mock, } from "../typechain"; import { deployFundTransfer } from "./helpers/deploy-fund-transfer"; -import { deployV2 } from "./helpers/deploy-v2"; import { readState } from "./helpers/read-state"; import { readCompareState } from "./helpers/read-compare-state"; +import { initImpl } from "./helpers/init-impl"; import { ContractStorageData } from "../scripts/utils/storage-check"; import { DEFAULT_ZERO_TOKEN_NAME, @@ -23,26 +25,27 @@ import { DEFAULT_MOCK_TOKEN_DECIMALS, DEFAULT_WITHDRAW_AMOUNT, DEFAULT_TOKEN_MINT_AMOUNT, - DEFAULT_TRANSFER_AMOUNT + DEFAULT_TRANSFER_AMOUNT, + IMPL_STORAGE_SLOT, } from "./helpers/constants"; -describe("zDAO Token Upgrade", () => { +describe("zDAO Token Upgrades", () => { let creator: SignerWithAddress; let user1: SignerWithAddress; let user2: SignerWithAddress; let tokenV1: ZeroDAOToken; let tokenV2: ZeroDAOTokenV2; + let tokenV3: ZeroDAOTokenV3; let mockToken: ERC20Mock; let preUpgradeState: ContractStorageData; + let preV3UpgradeState: ContractStorageData; before(async () => { [creator, user1, user2] = await hre.ethers.getSigners(); }); - describe("Token Upgrade Flow", () => { - - + describe("Token Upgrade Flow v1 => v2 => v3", () => { it("should deploy and fund V1 token with mock ERC20", async () => { // Call deploy-fund-transfer helper tokenV1 = await deployFundTransfer(creator, creator.address); @@ -55,10 +58,16 @@ describe("zDAO Token Upgrade", () => { // We need to find that mock token address from the deployment // For now, let's create our own mock token for testing the withdrawERC20 function const mockTokenFactory = new ERC20Mock__factory(creator); - mockToken = await mockTokenFactory.deploy(DEFAULT_TEST_MOCK_TOKEN_NAME, DEFAULT_TEST_MOCK_TOKEN_SYMBOL); + mockToken = await mockTokenFactory.deploy( + DEFAULT_TEST_MOCK_TOKEN_NAME, + DEFAULT_TEST_MOCK_TOKEN_SYMBOL + ); // Mint some mock tokens to the V1 contract for testing withdrawERC20 - const mintAmount = ethers.utils.parseUnits(DEFAULT_TEST_MINT_AMOUNT, DEFAULT_MOCK_TOKEN_DECIMALS); + const mintAmount = ethers.utils.parseUnits( + DEFAULT_TEST_MINT_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ); await mockToken.mint(tokenV1.address, mintAmount); const balance = await mockToken.balanceOf(tokenV1.address); @@ -84,17 +93,40 @@ describe("zDAO Token Upgrade", () => { const accounts = await hre.ethers.getSigners(); // Find the account that matches the proxy admin owner - upgrader = accounts.find(account => account.address.toLowerCase() === proxyAdminOwner.toLowerCase()) || creator; + upgrader = + accounts.find( + (account) => + account.address.toLowerCase() === proxyAdminOwner.toLowerCase() + ) || creator; // Upgrade from v1 to v2 using const tokenV2Factory = new ZeroDAOTokenV2__factory(upgrader); - tokenV2 = await hre.upgrades.upgradeProxy( + tokenV2 = (await hre.upgrades.upgradeProxy( tokenV1.address, tokenV2Factory - ) as ZeroDAOTokenV2; + )) as ZeroDAOTokenV2; + + // Same proxy address + expect(tokenV2.address).to.equal(tokenV1.address); - expect(tokenV2.address).to.equal(tokenV1.address); // Same proxy address + const paddedImplAddress = await hre.ethers.provider.getStorageAt( + tokenV2.address, + IMPL_STORAGE_SLOT + ); + + // Remove padding in bytes before comparing + const implAddress = + paddedImplAddress.slice(0, 2) + paddedImplAddress.slice(26); + + // Make sure that we also call to `initializeImplementation` from the implementation + // contract as well to ensure we don't risk losing ownership + const implOwner = await initImpl( + new ZeroDAOTokenV2__factory(creator), + implAddress + ); + + expect(implOwner).to.eq(creator.address); }); it("should read and compare state after upgrade", async () => { @@ -105,7 +137,6 @@ describe("zDAO Token Upgrade", () => { }); it("should test withdrawERC20 function and verify balances", async () => { - // Connect to the token as the owner const tokenV2AsOwner = tokenV2.connect(creator); @@ -114,10 +145,15 @@ describe("zDAO Token Upgrade", () => { expect(initialBalance).to.be.gt(0); // Check initial balance of recipient (creator) - const recipientInitialBalance = await mockToken.balanceOf(creator.address); + const recipientInitialBalance = await mockToken.balanceOf( + creator.address + ); // Define withdrawal amount - const withdrawAmount = ethers.utils.parseUnits(DEFAULT_WITHDRAW_AMOUNT, DEFAULT_MOCK_TOKEN_DECIMALS); + const withdrawAmount = ethers.utils.parseUnits( + DEFAULT_WITHDRAW_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ); expect(withdrawAmount).to.be.lte(initialBalance); // Call withdrawERC20 function as the token owner @@ -138,14 +174,20 @@ describe("zDAO Token Upgrade", () => { expect(finalContractBalance).to.equal(initialBalance.sub(withdrawAmount)); // Verify the recipient balance increased by the withdrawal amount - expect(recipientFinalBalance).to.equal(recipientInitialBalance.add(withdrawAmount)); + expect(recipientFinalBalance).to.equal( + recipientInitialBalance.add(withdrawAmount) + ); }); it("should verify token functionality is preserved after upgrade", async () => { // Get the token owner (who can call mint) const tokenOwnerAddress = await tokenV2.owner(); const accounts = await hre.ethers.getSigners(); - const ownerSigner = accounts.find(account => account.address.toLowerCase() === tokenOwnerAddress.toLowerCase()) || creator; + const ownerSigner = + accounts.find( + (account) => + account.address.toLowerCase() === tokenOwnerAddress.toLowerCase() + ) || creator; // Connect to the token as the owner const tokenV2AsOwner = tokenV2.connect(ownerSigner); @@ -168,5 +210,242 @@ describe("zDAO Token Upgrade", () => { expect(user1Balance).to.equal(mintAmount.sub(transferAmount)); expect(user2Balance).to.equal(transferAmount); }); + + it("should read state before V2 to V3 upgrade", async () => { + // Call readState helper to get state before upgrading to V3 + preV3UpgradeState = await readState(creator, tokenV2.address); + + expect(preV3UpgradeState).to.not.be.undefined; + expect(Array.isArray(preV3UpgradeState)).to.be.true; + expect(preV3UpgradeState.length).to.be.greaterThan(0); + }); + + it("should upgrade from V2 to V3", async () => { + // Get the proxy admin and its owner + const proxyAdmin = await hre.upgrades.admin.getInstance(); + const proxyAdminOwner = await proxyAdmin.owner(); + + // Get the signer for the proxy admin owner + let upgrader: SignerWithAddress; + const accounts = await hre.ethers.getSigners(); + + // Find the account that matches the proxy admin owner + upgrader = + accounts.find( + (account) => + account.address.toLowerCase() === proxyAdminOwner.toLowerCase() + ) || creator; + + // Upgrade from v2 to v3 + const tokenV3Factory = new ZeroDAOTokenV3__factory(upgrader); + + tokenV3 = (await hre.upgrades.upgradeProxy( + tokenV2.address, + tokenV3Factory + )) as ZeroDAOTokenV3; + + expect(tokenV3.address).to.equal(tokenV2.address); // Same proxy address + }); + + it("should read and compare state after V2 to V3 upgrade", async () => { + // Call readStateAndCompare helper to compare states + // This function throws internally if there is a discrepency between + // the pre and post upgrade states. By not throwing, we know it passes. + await readCompareState(creator, tokenV3.address, preV3UpgradeState); + }); + + it("should verify onlyOwner functions are removed in V3", async () => { + // Verify that remaining functions still exist + expect(tokenV3.transferBulk).to.be.a("function"); + expect(tokenV3.transferFromBulk).to.be.a("function"); + expect(tokenV3.transfer).to.be.a("function"); + expect(tokenV3.balanceOf).to.be.a("function"); + }); + + it("should verify snapshot functions are removed in V3", async () => { + // Test that snapshot function selector doesn't work + const snapshotSelector = "0x9711715a"; // snapshot() function selector + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: snapshotSelector, + }) + ).to.be.reverted; + }); + + it("should verify removed functions cannot be called with direct ABI manipulation", async () => { + // Test mint function selector + const mintSelector = "0x40c10f19"; // mint(address,uint256) + const mintAmount = ethers.utils.parseEther("1000"); + const mintCalldata = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256"], + [user1.address, mintAmount] + ); + + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: mintSelector + mintCalldata.slice(2), + }) + ).to.be.reverted; + + // Test burn function selector + const burnSelector = "0x9dc29fac"; // burn(address,uint256) + const burnAmount = ethers.utils.parseEther("100"); + const burnCalldata = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256"], + [user1.address, burnAmount] + ); + + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: burnSelector + burnCalldata.slice(2), + }) + ).to.be.reverted; + + // Test pause function selector + const pauseSelector = "0x8456cb59"; // pause() + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: pauseSelector, + }) + ).to.be.reverted; + + // Test withdrawERC20 function selector + const withdrawSelector = "0x01e33667"; // withdrawERC20(address,address,uint256) + const withdrawAmount = ethers.utils.parseEther("100"); + const withdrawCalldata = ethers.utils.defaultAbiCoder.encode( + ["address", "address", "uint256"], + [mockToken.address, user1.address, withdrawAmount] + ); + + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: withdrawSelector + withdrawCalldata.slice(2), + }) + ).to.be.reverted; + }); + + it("should verify burn-on-self-transfer functionality", async () => { + // Get initial balances and total supply + const transferAmount = ethers.utils.parseEther("50"); + const initialUser1Balance = await tokenV3.balanceOf(user1.address); + const initialTotalSupply = await tokenV3.totalSupply(); + const initialContractBalance = await tokenV3.balanceOf(tokenV3.address); + + // Transfer tokens to the contract itself (should trigger burn) + const tx = await tokenV3 + .connect(user1) + .transfer(tokenV3.address, transferAmount); + + // Should emit Transfer to contract and then Transfer from contract to zero (burn) + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, tokenV3.address, transferAmount); + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs( + tokenV3.address, + ethers.constants.AddressZero, + transferAmount + ); + + // Verify tokens were burned + const finalUser1Balance = await tokenV3.balanceOf(user1.address); + const finalTotalSupply = await tokenV3.totalSupply(); + const finalContractBalance = await tokenV3.balanceOf(tokenV3.address); + + expect(finalUser1Balance).to.equal( + initialUser1Balance.sub(transferAmount) + ); + expect(finalContractBalance).to.equal(initialContractBalance); // Should remain 0 + expect(finalTotalSupply).to.equal(initialTotalSupply.sub(transferAmount)); + }); + + it("should verify normal transfers still work in V3", async () => { + // Test normal transfer between users (should not trigger burn) + const transferAmount = ethers.utils.parseEther("25"); + const initialUser1Balance = await tokenV3.balanceOf(user1.address); + const initialUser2Balance = await tokenV3.balanceOf(user2.address); + const initialTotalSupply = await tokenV3.totalSupply(); + + const tx = await tokenV3 + .connect(user1) + .transfer(user2.address, transferAmount); + + // Should only emit one Transfer event (no burn) + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, user2.address, transferAmount); + + // Verify balances changed correctly + const finalUser1Balance = await tokenV3.balanceOf(user1.address); + const finalUser2Balance = await tokenV3.balanceOf(user2.address); + const finalTotalSupply = await tokenV3.totalSupply(); + + expect(finalUser1Balance).to.equal( + initialUser1Balance.sub(transferAmount) + ); + expect(finalUser2Balance).to.equal( + initialUser2Balance.add(transferAmount) + ); + expect(finalTotalSupply).to.equal(initialTotalSupply); // No change in total supply + }); + + it("should verify bulk transfer functions still work in V3", async () => { + const transferAmount = ethers.utils.parseEther("10"); + const initialUser1Balance = await tokenV3.balanceOf(user1.address); + const initialUser2Balance = await tokenV3.balanceOf(user2.address); + const initialCreatorBalance = await tokenV3.balanceOf(creator.address); + + // Test transferBulk + const tx = await tokenV3 + .connect(user1) + .transferBulk([user2.address, creator.address], transferAmount); + + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, user2.address, transferAmount); + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, creator.address, transferAmount); + + // Verify balances + const finalUser1Balance = await tokenV3.balanceOf(user1.address); + const finalUser2Balance = await tokenV3.balanceOf(user2.address); + const finalCreatorBalance = await tokenV3.balanceOf(creator.address); + + expect(finalUser1Balance).to.equal( + initialUser1Balance.sub(transferAmount.mul(2)) + ); + expect(finalUser2Balance).to.equal( + initialUser2Balance.add(transferAmount) + ); + expect(finalCreatorBalance).to.equal( + initialCreatorBalance.add(transferAmount) + ); + }); + + it("should verify contract after V3 upgrade", async () => { + // Verify that the contract owner still exists for storage compatibility + // but has no special privileges + expect(await tokenV3.owner()).to.equal(creator.address); + + // Verify that all privileged functions have been removed + // This has been tested in previous test cases + + // The only way to interact with the contract now is through: + // 1. Standard ERC20 functions (transfer, approve, etc.) + // 2. Bulk transfer functions + // 3. Burn-on-self-transfer functionality + + // Verify the contract still functions as a basic ERC20 + expect(await tokenV3.name()).to.equal(DEFAULT_ZERO_TOKEN_NAME); + expect(await tokenV3.symbol()).to.equal(DEFAULT_ZERO_TOKEN_SYMBOL); + expect(await tokenV3.totalSupply()).to.be.gt(0); + }); }); }); diff --git a/test/zDaoToken.ts b/test/zDaoToken.ts index 53f86be..bd9e260 100644 --- a/test/zDaoToken.ts +++ b/test/zDaoToken.ts @@ -2,10 +2,7 @@ import { BigNumber } from "@ethersproject/bignumber"; import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { expect } from "chai"; import { ethers } from "hardhat"; -import { - ZeroDAOToken, - ZeroDAOToken__factory, -} from "../typechain"; +import { ZeroDAOToken, ZeroDAOToken__factory } from "../typechain"; describe("zDAO Token", () => { let accounts: SignerWithAddress[]; diff --git a/test/zDaoTokenV2.ts b/test/zDaoTokenV2.ts index f1752a0..b48e66c 100644 --- a/test/zDaoTokenV2.ts +++ b/test/zDaoTokenV2.ts @@ -2,383 +2,332 @@ import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; import { expect } from "chai"; import { ethers } from "hardhat"; import { - ZeroDAOToken, - ZeroDAOToken__factory, ERC20Mock, ERC20Mock__factory, ZeroDAOTokenV2__factory, ZeroDAOTokenV2, } from "../typechain"; +import { + DEFAULT_V2_TOKEN_NAME, + DEFAULT_V2_TOKEN_SYMBOL, + DEFAULT_TEST_MOCK_TOKEN_NAME, + DEFAULT_TEST_MOCK_TOKEN_SYMBOL, + DEFAULT_MOCK_TOKEN_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS, + DEFAULT_V2_ADDITIONAL_AMOUNT, + DEFAULT_V2_TEST_AMOUNT, + DEFAULT_V2_INSUFFICIENT_AMOUNT, + DEFAULT_V2_MINT_AMOUNT, + DEFAULT_V2_BURN_AMOUNT, + DEFAULT_V2_PAUSE_TRANSFER_AMOUNT, + DEFAULT_V2_BULK_TRANSFER_AMOUNT, + DEFAULT_V2_BULK_TRANSFERFROM_AMOUNT, +} from "./helpers/constants"; import * as hre from "hardhat"; -import { compareStorageData, ContractStorageData, readContractStorage } from "../scripts/utils/storage-check"; -import { deployFundTransfer } from "./helpers/deploy-fund-transfer"; - -describe("zDAOToken => zDAOTokenV2 Upgrade Test", () => { +describe("zDAOTokenV2 Unit Tests", () => { let creator: SignerWithAddress; - - let newOwner: SignerWithAddress; + let owner: SignerWithAddress; let user1: SignerWithAddress; let user2: SignerWithAddress; - let zeroDAOToken: ZeroDAOToken; - let zeroDAOTokenV2: ZeroDAOTokenV2; + let tokenV2: ZeroDAOTokenV2; let mockToken: ERC20Mock; - let preUpgradeState: ContractStorageData; - // Update as needed for testing - const decimals = 6; - const testTokenAmount = ethers.utils.parseUnits("500000", decimals); + + // Use constants for testing + const testTokenAmount = ethers.utils.parseUnits( + DEFAULT_MOCK_TOKEN_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ); before(async () => { - [creator, newOwner, user1, user2] = await ethers.getSigners(); + [creator, owner, user1, user2] = await ethers.getSigners(); }); - describe("ZeroDAOToken to ZeroDAOTokenV2 upgrade test - mint function removal", () => { - it("Deploys original ZeroDAOToken contract", async () => { - // Deploy the original ZeroDAOToken using the original factory - // Use the `deployV1` Helper to that sets up the initial environment the same way it is used in scripts - // - Deploy ZeroDAOToken contract - // - Deploy an ERC20Mock contract, give funds to ZeroDAOToken - // - Transfer ownership to a given address - zeroDAOToken = await deployFundTransfer(creator, newOwner.address); - }); - - it("Deploys mock token and mints balance to deployed zeroDAOToken", async () => { - // Deploy ERC20Mock - const mockTokenFactory = new ERC20Mock__factory(creator); - mockToken = await mockTokenFactory.deploy("Test Mock Token", "TMT"); - await mockToken.deployed(); + describe("ZeroDAOTokenV2 Core Functionality", () => { + it("should deploy ZeroDAOTokenV2 contract", async () => { + // Deploy ZeroDAOTokenV2 + tokenV2 = (await hre.upgrades.deployProxy( + new ZeroDAOTokenV2__factory(creator), + [DEFAULT_V2_TOKEN_NAME, DEFAULT_V2_TOKEN_SYMBOL] + )) as ZeroDAOTokenV2; - await mockToken.mint(zeroDAOToken.address, testTokenAmount); + await tokenV2.deployed(); - expect(await mockToken.balanceOf(zeroDAOToken.address)).to.eq(testTokenAmount); - }); + // Verify deployment + expect(await tokenV2.name()).to.equal(DEFAULT_V2_TOKEN_NAME); + expect(await tokenV2.symbol()).to.equal(DEFAULT_V2_TOKEN_SYMBOL); + expect(await tokenV2.owner()).to.equal(creator.address); - it("Upgrades ZeroDAOToken to ZeroDAOTokenV2", async () => { - // Store pre-upgrade state - const preUpgradeName = await zeroDAOToken.name(); - const preUpgradeSymbol = await zeroDAOToken.symbol(); - const preUpgradeOwner = await zeroDAOToken.owner(); - const preUpgradeTotalSupply = await zeroDAOToken.totalSupply(); - const preUpgradeUser1Balance = await zeroDAOToken.balanceOf(newOwner.address); - const preUpgradeContractBalance = await mockToken.balanceOf(zeroDAOToken.address); - - preUpgradeState = await readContractStorage( - new ZeroDAOToken__factory(creator), - zeroDAOToken + // Deploy mock token for testing withdrawERC20 + const mockTokenFactory = new ERC20Mock__factory(creator); + mockToken = await mockTokenFactory.deploy( + DEFAULT_TEST_MOCK_TOKEN_NAME, + DEFAULT_TEST_MOCK_TOKEN_SYMBOL ); + await mockToken.deployed(); - // Perform the upgrade - use newOwner since they own the proxy admin - zeroDAOTokenV2 = await hre.upgrades.upgradeProxy( - zeroDAOToken.address, - new ZeroDAOTokenV2__factory(newOwner) - ) as ZeroDAOTokenV2; - - // Verify the upgrade was successful - expect(zeroDAOTokenV2.address).to.eq(zeroDAOToken.address); - - // Verify state variables are preserved - expect(await zeroDAOTokenV2.name()).to.eq(preUpgradeName); - expect(await zeroDAOTokenV2.symbol()).to.eq(preUpgradeSymbol); - expect(await zeroDAOTokenV2.owner()).to.eq(preUpgradeOwner); - expect(await zeroDAOTokenV2.totalSupply()).to.eq(preUpgradeTotalSupply); - expect(await zeroDAOTokenV2.balanceOf(newOwner.address)).to.eq(preUpgradeUser1Balance); - expect(await mockToken.balanceOf(zeroDAOToken.address)).to.eq(preUpgradeContractBalance); + await mockToken.mint(tokenV2.address, testTokenAmount); + expect(await mockToken.balanceOf(tokenV2.address)).to.eq(testTokenAmount); }); - it("calls withdrawERC20 to withdraw that balance amount to an EOA", async () => { + it("should test withdrawERC20 function with specific amount", async () => { const initialRecipientBalance = await mockToken.balanceOf(user1.address); - const initialContractBalance = await mockToken.balanceOf(zeroDAOTokenV2.address); + const initialContractBalance = await mockToken.balanceOf(tokenV2.address); - // Call withdrawERC20 function - use newOwner since they own the contract - const tx = await zeroDAOTokenV2.connect(newOwner).withdrawERC20( - mockToken.address, - user1.address, - testTokenAmount - ); + // Call withdrawERC20 function + const tx = await tokenV2 + .connect(creator) + .withdrawERC20(mockToken.address, user1.address, testTokenAmount); // Verify the transaction emitted the correct event await expect(tx) - .to.emit(zeroDAOTokenV2, "ERC20TokenWithdrawn") + .to.emit(tokenV2, "ERC20TokenWithdrawn") .withArgs(mockToken.address, user1.address, testTokenAmount); // Check contract balance (should be 0) - const finalContractBalance = await mockToken.balanceOf(zeroDAOTokenV2.address); - expect(finalContractBalance).to.eq(initialContractBalance.sub(testTokenAmount)); + const finalContractBalance = await mockToken.balanceOf(tokenV2.address); + expect(finalContractBalance).to.eq( + initialContractBalance.sub(testTokenAmount) + ); // Check recipient balance (should have received the tokens) const finalRecipientBalance = await mockToken.balanceOf(user1.address); - expect(finalRecipientBalance).to.eq(initialRecipientBalance.add(testTokenAmount)); + expect(finalRecipientBalance).to.eq( + initialRecipientBalance.add(testTokenAmount) + ); }); - it("tests withdrawERC20 with amount 0 (withdraw all)", async () => { + it("should test withdrawERC20 with amount 0 (withdraw all)", async () => { // First, give the contract some more tokens - const additionalAmount = ethers.utils.parseUnits("100000", decimals); - await mockToken.connect(creator).mint(zeroDAOTokenV2.address, additionalAmount); + const additionalAmount = ethers.utils.parseUnits( + DEFAULT_V2_ADDITIONAL_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ); + await mockToken.connect(creator).mint(tokenV2.address, additionalAmount); const initialRecipientBalance = await mockToken.balanceOf(user2.address); - // Call withdrawERC20 with amount 0 (should withdraw all) - use newOwner since they own the contract - const tx = zeroDAOTokenV2.connect(newOwner).withdrawERC20( + // Call withdrawERC20 with amount 0 (should withdraw all) + const tx = await tokenV2.connect(creator).withdrawERC20( mockToken.address, user2.address, 0 // This should withdraw total contract balance of `token` given ); // Verify the transaction emitted the correct event with the full amount - expect(await tx) - .to.emit(zeroDAOTokenV2, "ERC20TokenWithdrawn") + await expect(tx) + .to.emit(tokenV2, "ERC20TokenWithdrawn") .withArgs(mockToken.address, user2.address, additionalAmount); // Check final balances - const finalContractBalance = await mockToken.balanceOf(zeroDAOTokenV2.address); + const finalContractBalance = await mockToken.balanceOf(tokenV2.address); const finalRecipientBalance = await mockToken.balanceOf(user2.address); expect(finalContractBalance).to.eq(0); - expect(finalRecipientBalance).to.eq(initialRecipientBalance.add(additionalAmount)); + expect(finalRecipientBalance).to.eq( + initialRecipientBalance.add(additionalAmount) + ); }); - it("Fails when called by non-owner", async () => { + it("should fail when called by non-owner", async () => { // Give the contract some tokens first - const testAmount = ethers.utils.parseUnits("1000", decimals); - await mockToken.connect(creator).mint(zeroDAOTokenV2.address, testAmount); + const testAmount = ethers.utils.parseUnits( + DEFAULT_V2_TEST_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ); + await mockToken.connect(creator).mint(tokenV2.address, testAmount); - // Try to call withdrawERC20 from non-owner account (creator is not the owner, newOwner is) + // Try to call withdrawERC20 from non-owner account await expect( - zeroDAOTokenV2.connect(creator).withdrawERC20( - mockToken.address, - creator.address, - testAmount - ) + tokenV2 + .connect(user1) + .withdrawERC20(mockToken.address, user1.address, testAmount) ).to.be.revertedWith("Ownable: caller is not the owner"); }); - it("Fails when token or recipient address is zero", async () => { - const testAmount = ethers.utils.parseUnits("1000", decimals); + it("should fail when token or recipient address is zero", async () => { + const testAmount = ethers.utils.parseUnits( + DEFAULT_V2_TEST_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ); - // Test zero token address - use newOwner since they own the contract + // Test zero token address await expect( - zeroDAOTokenV2.connect(newOwner).withdrawERC20( - ethers.constants.AddressZero, - user1.address, - testAmount - ) + tokenV2 + .connect(creator) + .withdrawERC20( + ethers.constants.AddressZero, + user1.address, + testAmount + ) ).to.be.revertedWith("zDAOToken: Token address cannot be zero"); - // Test zero recipient address - use newOwner since they own the contract + // Test zero recipient address await expect( - zeroDAOTokenV2.connect(newOwner).withdrawERC20( - mockToken.address, - ethers.constants.AddressZero, - testAmount - ) + tokenV2 + .connect(creator) + .withdrawERC20( + mockToken.address, + ethers.constants.AddressZero, + testAmount + ) ).to.be.revertedWith("zDAOToken: Recipient address cannot be zero"); }); - it("Fails when contract has insufficient token balance", async () => { - // Test insufficient balance (contract has 1000, trying to withdraw 2000) - use newOwner since they own the contract + it("should fail when contract has insufficient token balance", async () => { + // Test insufficient balance await expect( - zeroDAOTokenV2.connect(newOwner).withdrawERC20( - mockToken.address, - user1.address, - ethers.utils.parseUnits("2000", decimals) - ) + tokenV2 + .connect(creator) + .withdrawERC20( + mockToken.address, + user1.address, + ethers.utils.parseUnits( + DEFAULT_V2_INSUFFICIENT_AMOUNT, + DEFAULT_MOCK_TOKEN_DECIMALS + ) + ) ).to.be.revertedWith("ERC20: transfer amount exceeds balance"); }); - it("Post-upgrade base state should match state pre-upgrade", async () => { - // Verify storage layout is compatible - const postUpgradeState = await readContractStorage( - new ZeroDAOTokenV2__factory(creator), - zeroDAOTokenV2 - ); - - compareStorageData( - preUpgradeState, - postUpgradeState, - ); - }); - }); - - describe("post-upgrade core functionality", () => { - it("owner can mint; standard transfer works", async () => { - expect(await zeroDAOTokenV2.owner()).to.eq(newOwner.address); - expect(zeroDAOTokenV2.address).to.eq(zeroDAOToken.address); - - const mintCreator = ethers.utils.parseEther("1000"); - const mintUser1 = ethers.utils.parseEther("100"); - - await expect( - zeroDAOTokenV2.connect(creator).mint(creator.address, mintUser1) - ).to.be.revertedWith("Ownable: caller is not the owner"); - - const creatorBalBefore = await zeroDAOTokenV2.balanceOf(creator.address); - const user1BalBefore = await zeroDAOTokenV2.balanceOf(newOwner.address); - const supplyBefore = await zeroDAOTokenV2.totalSupply(); - - await zeroDAOTokenV2.connect(newOwner).mint(creator.address, mintCreator); - await zeroDAOTokenV2.connect(newOwner).mint(newOwner.address, mintUser1); - - expect(await zeroDAOTokenV2.balanceOf(creator.address)).to.eq(creatorBalBefore.add(mintCreator)); - expect(await zeroDAOTokenV2.balanceOf(newOwner.address)).to.eq(user1BalBefore.add(mintUser1)); - expect(await zeroDAOTokenV2.totalSupply()).to.eq(supplyBefore.add(mintCreator).add(mintUser1)); - - const transferAmt = ethers.utils.parseEther("10"); - const creatorBalBeforeTransfer = await zeroDAOTokenV2.balanceOf(creator.address); - const user1BalBeforeTransfer = await zeroDAOTokenV2.balanceOf(newOwner.address); - - await zeroDAOTokenV2.connect(creator).transfer(newOwner.address, transferAmt); - - expect(await zeroDAOTokenV2.balanceOf(creator.address)).to.eq(creatorBalBeforeTransfer.sub(transferAmt)); - expect(await zeroDAOTokenV2.balanceOf(newOwner.address)).to.eq(user1BalBeforeTransfer.add(transferAmt)); - }); - - it("approve and transferFrom update balances and allowance", async () => { - const approveAmt = ethers.utils.parseEther("50"); - await zeroDAOTokenV2.connect(creator).approve(user1.address, approveAmt); - expect(await zeroDAOTokenV2.allowance(creator.address, user1.address)).to.eq(approveAmt); - - const tfAmt = ethers.utils.parseEther("20"); - const creatorBalBefore = await zeroDAOTokenV2.balanceOf(creator.address); - const user3BalBefore = await zeroDAOTokenV2.balanceOf(user2.address); - const allowanceBefore = await zeroDAOTokenV2.allowance(creator.address, user1.address); + it("should allow owner to mint tokens", async () => { + const mintAmount = ethers.utils.parseEther(DEFAULT_V2_MINT_AMOUNT); + const initialBalance = await tokenV2.balanceOf(user1.address); + const initialSupply = await tokenV2.totalSupply(); - await zeroDAOTokenV2.connect(user1).transferFrom(creator.address, user2.address, tfAmt); + await tokenV2.connect(creator).mint(user1.address, mintAmount); - expect(await zeroDAOTokenV2.balanceOf(creator.address)).to.eq(creatorBalBefore.sub(tfAmt)); - expect(await zeroDAOTokenV2.balanceOf(user2.address)).to.eq(user3BalBefore.add(tfAmt)); - expect(await zeroDAOTokenV2.allowance(creator.address, user1.address)).to.eq(allowanceBefore.sub(tfAmt)); + expect(await tokenV2.balanceOf(user1.address)).to.eq( + initialBalance.add(mintAmount) + ); + expect(await tokenV2.totalSupply()).to.eq(initialSupply.add(mintAmount)); }); - it("snapshot authorization and events work", async () => { - // Owner can snapshot - const nextId = await zeroDAOTokenV2.connect(newOwner).callStatic.snapshot(); - await expect(zeroDAOTokenV2.connect(newOwner).snapshot()).to.not.be.reverted; - const expectedNext = nextId.add(1); - expect(await zeroDAOTokenV2.connect(newOwner).callStatic.snapshot()).to.eq(expectedNext); - - // Non-owner unauthorized by default - await expect(zeroDAOTokenV2.connect(creator).snapshot()).to.be.revertedWith("zDAOToken: Not authorized to snapshot"); - - // Authorize creator - await expect(zeroDAOTokenV2.connect(newOwner).authorizeSnapshotter(creator.address)) - .to.emit(zeroDAOTokenV2, "AuthorizedSnapshotter") - .withArgs(creator.address); + it("should allow owner to burn tokens", async () => { + const burnAmount = ethers.utils.parseEther(DEFAULT_V2_BURN_AMOUNT); + const initialBalance = await tokenV2.balanceOf(user1.address); + const initialSupply = await tokenV2.totalSupply(); - // Now creator can snapshot - await expect(zeroDAOTokenV2.connect(creator).snapshot()).to.not.be.reverted; + await tokenV2.connect(creator).burn(user1.address, burnAmount); - // Deauthorize and ensure it reverts again - await expect(zeroDAOTokenV2.connect(newOwner).deauthorizeSnapshotter(creator.address)) - .to.emit(zeroDAOTokenV2, "DeauthorizedSnapshotter") - .withArgs(creator.address); - - await expect(zeroDAOTokenV2.connect(creator).snapshot()).to.be.revertedWith("zDAOToken: Not authorized to snapshot"); + expect(await tokenV2.balanceOf(user1.address)).to.eq( + initialBalance.sub(burnAmount) + ); + expect(await tokenV2.totalSupply()).to.eq(initialSupply.sub(burnAmount)); }); - it("pause/unpause gates token operations", async () => { - await zeroDAOTokenV2.connect(newOwner).pause(); - - await expect( - zeroDAOTokenV2.connect(creator).transfer(newOwner.address, ethers.utils.parseEther("1")) - ).to.be.revertedWith("ERC20Pausable: token transfer while paused"); + it("should allow owner to pause and unpause", async () => { + // Pause the contract + await tokenV2.connect(creator).pause(); + // Transfers should fail when paused await expect( - zeroDAOTokenV2.connect(newOwner).mint(creator.address, ethers.utils.parseEther("1")) + tokenV2 + .connect(user1) + .transfer( + user2.address, + ethers.utils.parseEther(DEFAULT_V2_PAUSE_TRANSFER_AMOUNT) + ) ).to.be.revertedWith("ERC20Pausable: token transfer while paused"); - await zeroDAOTokenV2.connect(newOwner).unpause(); + // Unpause the contract + await tokenV2.connect(creator).unpause(); + // Transfers should work when unpaused await expect( - zeroDAOTokenV2.connect(creator).transfer(newOwner.address, ethers.utils.parseEther("1")) + tokenV2 + .connect(user1) + .transfer( + user2.address, + ethers.utils.parseEther(DEFAULT_V2_PAUSE_TRANSFER_AMOUNT) + ) ).to.not.be.reverted; }); - it("transferBulk distributes to multiple recipients and validates inputs", async () => { - const recipients = [user1.address, user2.address]; - const per = ethers.utils.parseEther("5"); - const total = per.mul(recipients.length); - - // Ensure creator has enough funds for this test - const creatorBal = await zeroDAOTokenV2.balanceOf(creator.address); - if (creatorBal.lt(total)) { - await zeroDAOTokenV2.connect(creator).mint(creator.address, total.sub(creatorBal)); - } - - const creatorBefore = await zeroDAOTokenV2.balanceOf(creator.address); - const user2Before = await zeroDAOTokenV2.balanceOf(user1.address); - const user3Before = await zeroDAOTokenV2.balanceOf(user2.address); - - await zeroDAOTokenV2.connect(creator).transferBulk(recipients, per); + it("should handle snapshot functionality", async () => { + // Owner can snapshot + const snapshotId = await tokenV2.connect(creator).callStatic.snapshot(); + await tokenV2.connect(creator).snapshot(); - expect(await zeroDAOTokenV2.balanceOf(creator.address)).to.eq(creatorBefore.sub(total)); - expect(await zeroDAOTokenV2.balanceOf(user1.address)).to.eq(user2Before.add(per)); - expect(await zeroDAOTokenV2.balanceOf(user2.address)).to.eq(user3Before.add(per)); + // Authorize user1 to take snapshots + await expect(tokenV2.connect(creator).authorizeSnapshotter(user1.address)) + .to.emit(tokenV2, "AuthorizedSnapshotter") + .withArgs(user1.address); - // Zero address recipient should revert - await expect( - zeroDAOTokenV2.connect(creator).transferBulk([ethers.constants.AddressZero], per) - ).to.be.revertedWith("ERC20: transfer to the zero address"); + // User1 can now snapshot + await expect(tokenV2.connect(user1).snapshot()).to.not.be.reverted; - // Insufficient balance should revert - const tooBig = ethers.utils.parseEther("1000000000"); + // Deauthorize user1 await expect( - zeroDAOTokenV2.connect(newOwner).transferBulk([user1.address], tooBig) - ).to.be.revertedWith("ERC20: transfer amount exceeds balance"); + tokenV2.connect(creator).deauthorizeSnapshotter(user1.address) + ) + .to.emit(tokenV2, "DeauthorizedSnapshotter") + .withArgs(user1.address); + + // User1 can no longer snapshot + await expect(tokenV2.connect(user1).snapshot()).to.be.revertedWith( + "zDAOToken: Not authorized to snapshot" + ); }); - it("transferFromBulk uses allowance and updates balances and allowance", async () => { - const recipients = [user1.address, user2.address, newOwner.address]; - const per = ethers.utils.parseEther("2"); - const total = per.mul(recipients.length); - - // Ensure creator has enough and approve user1 to spend - const creatorBal = await zeroDAOTokenV2.balanceOf(creator.address); - if (creatorBal.lt(total)) { - await zeroDAOTokenV2.connect(creator).mint(creator.address, total.sub(creatorBal)); - } - - await expect( - zeroDAOTokenV2.connect(newOwner).transferFromBulk(creator.address, recipients, per) - ).to.be.revertedWith("ERC20: transfer total exceeds allowance"); + it("should handle bulk transfers", async () => { + const transferAmount = ethers.utils.parseEther( + DEFAULT_V2_BULK_TRANSFER_AMOUNT + ); + const recipients = [user1.address, user2.address]; - await zeroDAOTokenV2.connect(creator).approve(newOwner.address, total); + // Ensure creator has enough tokens + await tokenV2 + .connect(creator) + .mint(creator.address, transferAmount.mul(recipients.length)); - const creatorBefore = await zeroDAOTokenV2.balanceOf(creator.address); - const allowancesBefore = await zeroDAOTokenV2.allowance(creator.address, newOwner.address); - const user1Before = await zeroDAOTokenV2.balanceOf(newOwner.address); - const user2Before = await zeroDAOTokenV2.balanceOf(user1.address); - const user3Before = await zeroDAOTokenV2.balanceOf(user2.address); + const initialCreatorBalance = await tokenV2.balanceOf(creator.address); + const initialUser1Balance = await tokenV2.balanceOf(user1.address); + const initialUser2Balance = await tokenV2.balanceOf(user2.address); - await zeroDAOTokenV2.connect(newOwner).transferFromBulk(creator.address, recipients, per); + await tokenV2.connect(creator).transferBulk(recipients, transferAmount); - expect(await zeroDAOTokenV2.balanceOf(creator.address)).to.eq(creatorBefore.sub(total)); - expect(await zeroDAOTokenV2.balanceOf(newOwner.address)).to.eq(user1Before.add(per)); - expect(await zeroDAOTokenV2.balanceOf(user1.address)).to.eq(user2Before.add(per)); - expect(await zeroDAOTokenV2.balanceOf(user2.address)).to.eq(user3Before.add(per)); - expect(await zeroDAOTokenV2.allowance(creator.address, newOwner.address)).to.eq(allowancesBefore.sub(total)); + expect(await tokenV2.balanceOf(creator.address)).to.eq( + initialCreatorBalance.sub(transferAmount.mul(recipients.length)) + ); + expect(await tokenV2.balanceOf(user1.address)).to.eq( + initialUser1Balance.add(transferAmount) + ); + expect(await tokenV2.balanceOf(user2.address)).to.eq( + initialUser2Balance.add(transferAmount) + ); }); - it("owner burn reduces holder balance and total supply", async () => { - // Ensure user1 has enough to burn - const burnAmt = ethers.utils.parseEther("10"); - const user1Bal = await zeroDAOTokenV2.balanceOf(newOwner.address); - if (user1Bal.lt(burnAmt)) { - await zeroDAOTokenV2.connect(newOwner).mint(newOwner.address, burnAmt.sub(user1Bal)); - } - - await expect(zeroDAOTokenV2.connect(creator).burn(newOwner.address, burnAmt)).to.be.revertedWith( - "Ownable: caller is not the owner" + it("should handle bulk transferFrom", async () => { + const transferAmount = ethers.utils.parseEther( + DEFAULT_V2_BULK_TRANSFERFROM_AMOUNT ); + const recipients = [user1.address, user2.address]; + const totalAmount = transferAmount.mul(recipients.length); - const user1Before = await zeroDAOTokenV2.balanceOf(newOwner.address); - const supplyBefore = await zeroDAOTokenV2.totalSupply(); + // Ensure creator has enough tokens and approve user1 + await tokenV2.connect(creator).mint(creator.address, totalAmount); + await tokenV2.connect(creator).approve(user1.address, totalAmount); - await zeroDAOTokenV2.connect(newOwner).burn(newOwner.address, burnAmt); + const initialCreatorBalance = await tokenV2.balanceOf(creator.address); + const initialUser1Balance = await tokenV2.balanceOf(user1.address); + const initialUser2Balance = await tokenV2.balanceOf(user2.address); - expect(await zeroDAOTokenV2.balanceOf(newOwner.address)).to.eq(user1Before.sub(burnAmt)); - expect(await zeroDAOTokenV2.totalSupply()).to.eq(supplyBefore.sub(burnAmt)); + await tokenV2 + .connect(user1) + .transferFromBulk(creator.address, recipients, transferAmount); + + expect(await tokenV2.balanceOf(creator.address)).to.eq( + initialCreatorBalance.sub(totalAmount) + ); + expect(await tokenV2.balanceOf(user1.address)).to.eq( + initialUser1Balance.add(transferAmount) + ); + expect(await tokenV2.balanceOf(user2.address)).to.eq( + initialUser2Balance.add(transferAmount) + ); }); }); }); diff --git a/test/zDaoTokenV3.ts b/test/zDaoTokenV3.ts new file mode 100644 index 0000000..a30014a --- /dev/null +++ b/test/zDaoTokenV3.ts @@ -0,0 +1,339 @@ +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { + ZeroDAOTokenV2__factory, + ZeroDAOTokenV2, + ZeroDAOTokenV3__factory, + ZeroDAOTokenV3, +} from "../typechain"; +import { + DEFAULT_V3_TOKEN_NAME, + DEFAULT_V3_TOKEN_SYMBOL, + DEFAULT_V3_DECIMALS, + DEFAULT_V3_INITIAL_MINT_AMOUNT, + DEFAULT_V3_INITIAL_SUPPLY, + DEFAULT_V3_ABI_TEST_AMOUNT, + DEFAULT_V3_ABI_BURN_AMOUNT, + DEFAULT_V3_BURN_TRANSFER_AMOUNT, + DEFAULT_V3_NORMAL_TRANSFER_AMOUNT, + DEFAULT_V3_BULK_TRANSFER_AMOUNT, + DEFAULT_V3_BULK_TRANSFERFROM_AMOUNT, +} from "./helpers/constants"; + +import * as hre from "hardhat"; + +describe("zDAOTokenV3 Unit Tests", () => { + let creator: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + let tokenV2: ZeroDAOTokenV2; + let tokenV3: ZeroDAOTokenV3; + + // Use constants for testing + const initialMintAmount = ethers.utils.parseUnits( + DEFAULT_V3_INITIAL_MINT_AMOUNT, + DEFAULT_V3_DECIMALS + ); + + before(async () => { + [creator, user1, user2, user3] = await hre.ethers.getSigners(); + }); + + describe("ZeroDAOTokenV3 Core Functionality", () => { + it("should deploy ZeroDAOTokenV2 and mint tokens to users", async () => { + // Deploy ZeroDAOTokenV2 first + tokenV2 = (await hre.upgrades.deployProxy( + new ZeroDAOTokenV2__factory(creator), + [DEFAULT_V3_TOKEN_NAME, DEFAULT_V3_TOKEN_SYMBOL] + )) as ZeroDAOTokenV2; + + await tokenV2.deployed(); + + // Verify deployment + expect(await tokenV2.name()).to.equal(DEFAULT_V3_TOKEN_NAME); + expect(await tokenV2.symbol()).to.equal(DEFAULT_V3_TOKEN_SYMBOL); + expect(await tokenV2.owner()).to.equal(creator.address); + + // Mint tokens to users for testing + const mintAmount = ethers.utils.parseEther("10000"); // 10,000 tokens per user + await tokenV2.connect(creator).mint(user1.address, mintAmount); + await tokenV2.connect(creator).mint(user2.address, mintAmount); + await tokenV2.connect(creator).mint(user3.address, mintAmount); + + // Verify balances + expect(await tokenV2.balanceOf(user1.address)).to.equal(mintAmount); + expect(await tokenV2.balanceOf(user2.address)).to.equal(mintAmount); + expect(await tokenV2.balanceOf(user3.address)).to.equal(mintAmount); + }); + + it("should upgrade from V2 to V3", async () => { + // Store pre-upgrade state + const preUpgradeName = await tokenV2.name(); + const preUpgradeSymbol = await tokenV2.symbol(); + const preUpgradeOwner = await tokenV2.owner(); + const preUpgradeTotalSupply = await tokenV2.totalSupply(); + const preUpgradeUser1Balance = await tokenV2.balanceOf(user1.address); + const preUpgradeUser2Balance = await tokenV2.balanceOf(user2.address); + const preUpgradeUser3Balance = await tokenV2.balanceOf(user3.address); + + // Perform the upgrade + tokenV3 = (await hre.upgrades.upgradeProxy( + tokenV2.address, + new ZeroDAOTokenV3__factory(creator) + )) as ZeroDAOTokenV3; + + expect(tokenV3.address).to.equal(tokenV2.address); + + // Verify state variables are preserved + expect(await tokenV3.name()).to.equal(preUpgradeName); + expect(await tokenV3.symbol()).to.equal(preUpgradeSymbol); + expect(await tokenV3.owner()).to.equal(preUpgradeOwner); + expect(await tokenV3.totalSupply()).to.equal(preUpgradeTotalSupply); + expect(await tokenV3.balanceOf(user1.address)).to.equal( + preUpgradeUser1Balance + ); + expect(await tokenV3.balanceOf(user2.address)).to.equal( + preUpgradeUser2Balance + ); + expect(await tokenV3.balanceOf(user3.address)).to.equal( + preUpgradeUser3Balance + ); + }); + + it("should confirm onlyOwner functions are not present", async () => { + // Check that onlyOwner functions are not accessible in V3 + expect((tokenV3 as any).mint).to.be.undefined; + expect((tokenV3 as any).burn).to.be.undefined; + expect((tokenV3 as any).pause).to.be.undefined; + expect((tokenV3 as any).unpause).to.be.undefined; + expect((tokenV3 as any).snapshot).to.be.undefined; + expect((tokenV3 as any).authorizeSnapshotter).to.be.undefined; + expect((tokenV3 as any).deauthorizeSnapshotter).to.be.undefined; + expect((tokenV3 as any).withdrawERC20).to.be.undefined; + + // Verify that remaining functions still exist + expect(tokenV3.transferBulk).to.be.a("function"); + expect(tokenV3.transferFromBulk).to.be.a("function"); + expect(tokenV3.transfer).to.be.a("function"); + expect(tokenV3.balanceOf).to.be.a("function"); + }); + + it("should confirm removed functions cannot be called with direct ABI manipulation", async () => { + // Test mint function selector + const mintSelector = "0x40c10f19"; // mint(address,uint256) + const mintAmount = ethers.utils.parseEther(DEFAULT_V3_ABI_TEST_AMOUNT); + const mintCalldata = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256"], + [user3.address, mintAmount] + ); + + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: mintSelector + mintCalldata.slice(2), + }) + ).to.be.reverted; + + // Test burn function selector + const burnSelector = "0x9dc29fac"; // burn(address,uint256) + const burnCalldata = ethers.utils.defaultAbiCoder.encode( + ["address", "uint256"], + [user1.address, ethers.utils.parseEther(DEFAULT_V3_ABI_BURN_AMOUNT)] + ); + + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: burnSelector + burnCalldata.slice(2), + }) + ).to.be.reverted; + + // Test pause function selector + const pauseSelector = "0x8456cb59"; // pause() + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: pauseSelector, + }) + ).to.be.reverted; + + // Test snapshot function selector + const snapshotSelector = "0x9711715a"; // snapshot() function selector + await expect( + creator.sendTransaction({ + to: tokenV3.address, + data: snapshotSelector, + }) + ).to.be.reverted; + }); + + it("should test burn-on-self-transfer functionality", async () => { + // Test burn-on-self-transfer with user1 who has tokens from the upgrade + const transferAmount = ethers.utils.parseEther( + DEFAULT_V3_BURN_TRANSFER_AMOUNT + ); + const user1Balance = await tokenV3.balanceOf(user1.address); + const initialTotalSupply = await tokenV3.totalSupply(); + + // Ensure user1 has enough tokens for the test + expect(user1Balance).to.be.gte(transferAmount); + + // Transfer to self (contract address) should burn tokens + const tx = await tokenV3 + .connect(user1) + .transfer(tokenV3.address, transferAmount); + + // Should emit Transfer to contract and then Transfer from contract to zero (burn) + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, tokenV3.address, transferAmount); + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs( + tokenV3.address, + ethers.constants.AddressZero, + transferAmount + ); + + // Verify tokens were burned + expect(await tokenV3.balanceOf(user1.address)).to.equal( + user1Balance.sub(transferAmount) + ); + expect(await tokenV3.balanceOf(tokenV3.address)).to.equal(0); // Contract should have 0 balance + expect(await tokenV3.totalSupply()).to.equal( + initialTotalSupply.sub(transferAmount) + ); + }); + + it("should test normal transfers work correctly", async () => { + // Test that normal transfers between users work without burning + const transferAmount = ethers.utils.parseEther( + DEFAULT_V3_NORMAL_TRANSFER_AMOUNT + ); + const user1Balance = await tokenV3.balanceOf(user1.address); + const user2Balance = await tokenV3.balanceOf(user2.address); + const initialTotalSupply = await tokenV3.totalSupply(); + + // Ensure user1 has enough tokens for the test + expect(user1Balance).to.be.gte(transferAmount); + + const tx = await tokenV3 + .connect(user1) + .transfer(user2.address, transferAmount); + + // Should only emit one Transfer event (no burn) + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, user2.address, transferAmount); + + // Verify balances changed correctly + expect(await tokenV3.balanceOf(user1.address)).to.equal( + user1Balance.sub(transferAmount) + ); + expect(await tokenV3.balanceOf(user2.address)).to.equal( + user2Balance.add(transferAmount) + ); + expect(await tokenV3.totalSupply()).to.equal(initialTotalSupply); // No change in total supply + }); + + it("should test bulk transfer functions", async () => { + const transferAmount = ethers.utils.parseEther( + DEFAULT_V3_BULK_TRANSFER_AMOUNT + ); + const recipients = [user2.address, user3.address]; + const totalAmount = transferAmount.mul(recipients.length); + const user1Balance = await tokenV3.balanceOf(user1.address); + + // Ensure user1 has enough tokens for the test + expect(user1Balance).to.be.gte(totalAmount); + + const initialUser2Balance = await tokenV3.balanceOf(user2.address); + const initialUser3Balance = await tokenV3.balanceOf(user3.address); + + const tx = await tokenV3 + .connect(user1) + .transferBulk(recipients, transferAmount); + + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, user2.address, transferAmount); + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, user3.address, transferAmount); + + // Verify balances + expect(await tokenV3.balanceOf(user1.address)).to.equal( + user1Balance.sub(totalAmount) + ); + expect(await tokenV3.balanceOf(user2.address)).to.equal( + initialUser2Balance.add(transferAmount) + ); + expect(await tokenV3.balanceOf(user3.address)).to.equal( + initialUser3Balance.add(transferAmount) + ); + }); + + it("should test bulk transferFrom functions", async () => { + const transferAmount = ethers.utils.parseEther( + DEFAULT_V3_BULK_TRANSFERFROM_AMOUNT + ); + const recipients = [user2.address, user3.address]; + const totalAmount = transferAmount.mul(recipients.length); + const user1Balance = await tokenV3.balanceOf(user1.address); + + // Ensure user1 has enough tokens for the test + expect(user1Balance).to.be.gte(totalAmount); + + // First approve creator to spend from user1 + await tokenV3.connect(user1).approve(creator.address, totalAmount); + + const initialUser1Balance = await tokenV3.balanceOf(user1.address); + const initialUser2Balance = await tokenV3.balanceOf(user2.address); + const initialUser3Balance = await tokenV3.balanceOf(user3.address); + + const tx = await tokenV3 + .connect(creator) + .transferFromBulk(user1.address, recipients, transferAmount); + + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, user2.address, transferAmount); + await expect(tx) + .to.emit(tokenV3, "Transfer") + .withArgs(user1.address, user3.address, transferAmount); + + // Verify balances + expect(await tokenV3.balanceOf(user1.address)).to.equal( + initialUser1Balance.sub(totalAmount) + ); + expect(await tokenV3.balanceOf(user2.address)).to.equal( + initialUser2Balance.add(transferAmount) + ); + expect(await tokenV3.balanceOf(user3.address)).to.equal( + initialUser3Balance.add(transferAmount) + ); + }); + + it("confirms contract state", async () => { + // Verify that the contract owner exists for storage compatibility + // but has no special privileges + expect(await tokenV3.owner()).to.equal(creator.address); + + // Verify that all privileged functions have been removed + // This has been tested in previous test cases + + // The only way to interact with the contract now is through: + // 1. Standard ERC20 functions (transfer, approve, etc.) + // 2. Bulk transfer functions + // 3. Burn-on-self-transfer functionality + + // Verify the contract still functions as a basic ERC20 + expect(await tokenV3.name()).to.equal(DEFAULT_V3_TOKEN_NAME); + expect(await tokenV3.symbol()).to.equal(DEFAULT_V3_TOKEN_SYMBOL); + expect(await tokenV3.totalSupply()).to.be.gt(0); + }); + }); +});