Hardhat Developer Workflow¶
by Kevin Neilson & Erin Shaben
Introduction¶
In this tutorial, we'll walk through the Hardhat development environment in the context of launching a pooled staking DAO contract. We'll walk through the typical developer workflow in detail from start to finish.
We'll assemble the components of the staking DAO and compile the necessary contracts. Then, we'll build a test suite with a variety of test cases relevant to our staking DAO, and run it against a local development node. Finally, we'll deploy the staking DAO to both Moonbase Alpha and Moonbeam and verify the contracts via the Hardhat Etherscan plugin. If this is your first time exploring Hardhat, you may wish to start with the introduction to Hardhat guide.
Checking Prerequisites¶
To get started, you will need the following:
- A Moonbase Alpha account funded with DEV. You can get DEV tokens for testing on Moonbase Alpha once every 24 hours from the Moonbase Alpha Faucet
- A Moonscan API Key
- For the Testing section, you'll need to have a local Moonbeam node up and running
- To test out the examples in this guide on Moonbeam or Moonriver, you will need to have your own endpoint and API key, which you can get from one of the supported Endpoint Providers
Creating a Hardhat Project¶
You will need to create a Hardhat project if you don't already have one. You can create one by completing the following steps:
-
Create a directory for your project
mkdir stakingDAO && cd stakingDAO
-
Initialize the project which will create a
package.json
filenpm init -y
-
Install Hardhat
npm install hardhat
-
Create a project
npx hardhat init
Note
npx
is used to run executables installed locally in your project. Although Hardhat can be installed globally, it is recommended to install it locally in each project so that you can control the version on a project by project basis. -
A menu will appear allowing you to create a new project or use a sample project. For this example, you can choose Create an empty hardhat.config.js
👷 Welcome to Hardhat v2.22.2 👷
What do you want to do? … Create a JavaScript project Create a TypeScript project Create a TypeScript project (with Viem) Quit
This will create a Hardhat config file (hardhat.config.js
) in your project directory.
Add Smart Contracts¶
The smart contract featured in this tutorial is more complex than the one in the Introduction to Hardhat but the nature of the contract means it's perfect to demonstrate some of the advanced capabilities of Hardhat. DelegationDAO.sol
is a pooled staking DAO that uses StakingInterface.sol
to autonomously delegate to a collator when it reaches a determined threshold. Pooled staking contracts such as DelegationDAO.sol
allow delegators with less than the protocol minimum bond to join together to delegate their pooled funds and earn a share of staking rewards.
Note
DelegationDAO.sol
is unreviewed and unaudited. It is designed only for demonstration purposes and not intended for production use. It may contain bugs or logic errors that could result in loss of funds.
To get started, take the following steps:
-
Create a
contracts
directory to hold your project's smart contractsmkdir contracts
-
Create a new file called
DelegationDAO.sol
touch contracts/DelegationDAO.sol
-
Copy and paste the contents of DelegationDAO.sol into
DelegationDAO.sol
DelegationDAO.sol
// SPDX-License-Identifier: GPL-3.0-only // This is a PoC to use the staking precompile wrapper as a Solidity developer. pragma solidity >=0.8.0; import "./StakingInterface.sol"; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/utils/Address.sol"; contract DelegationDAO is AccessControl { // Role definition for contract members bytes32 public constant MEMBER = keccak256("MEMBER"); // Possible states for the DAO to be in: // COLLECTING: the DAO is collecting funds before creating a delegation once the minimum delegation stake has been reached // STAKING: the DAO has an active delegation // REVOKING: the DAO has scheduled a delegation revoke // REVOKED: the scheduled revoke has been executed enum daoState { COLLECTING, STAKING, REVOKING, REVOKED } // Current state that the DAO is in daoState public currentState; // Member stakes (doesnt include rewards, represents member shares) mapping(address => uint256) public memberStakes; // Total Staking Pool (doesnt include rewards, represents total shares) uint256 public totalStake; // The ParachainStaking wrapper at the known pre-compile address. This will be used to make // all calls to the underlying staking solution ParachainStaking public staking; // Minimum Delegation Amount uint256 public constant minDelegationStk = 5 ether; // Moonbeam Staking Precompile address address public constant stakingPrecompileAddress = 0x0000000000000000000000000000000000000800; // The collator that this DAO is currently nominating address public target; // Event for a member deposit event deposit(address indexed _from, uint _value); // Event for a member withdrawal event withdrawal(address indexed _from, address indexed _to, uint _value); // Initialize a new DelegationDao dedicated to delegating to the given collator target. constructor(address _target, address admin) { // Directly grant roles _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(MEMBER, admin); //Sets the collator that this DAO nominating target = _target; // Initializes Moonbeam's parachain staking precompile staking = ParachainStaking(stakingPrecompileAddress); //Initialize the DAO state currentState = daoState.COLLECTING; } // Simple getter to return the target collator of the DAO function getTarget() public view returns (address) { return target; } // Grant a user the role of admin function grant_admin( address newAdmin ) public onlyRole(DEFAULT_ADMIN_ROLE) onlyRole(MEMBER) { grantRole(DEFAULT_ADMIN_ROLE, newAdmin); grantRole(MEMBER, newAdmin); } // Grant a user membership function grant_member( address newMember ) public onlyRole(DEFAULT_ADMIN_ROLE) { grantRole(MEMBER, newMember); } // Revoke a user membership function remove_member( address payable exMember ) public onlyRole(DEFAULT_ADMIN_ROLE) { revokeRole(MEMBER, exMember); } // Increase member stake via a payable function and automatically stake the added amount if possible function add_stake() external payable onlyRole(MEMBER) { if (currentState == daoState.STAKING) { // Sanity check if (!staking.isDelegator(address(this))) { revert("The DAO is in an inconsistent state."); } memberStakes[msg.sender] = memberStakes[msg.sender] + msg.value; totalStake = totalStake + msg.value; emit deposit(msg.sender, msg.value); staking.delegatorBondMore(target, msg.value); } else if (currentState == daoState.COLLECTING) { memberStakes[msg.sender] = memberStakes[msg.sender] + msg.value; totalStake = totalStake + msg.value; emit deposit(msg.sender, msg.value); if (totalStake < minDelegationStk) { return; } else { //initialiate the delegation and change the state staking.delegate( target, address(this).balance, staking.candidateDelegationCount(target), staking.delegatorDelegationCount(address(this)) ); currentState = daoState.STAKING; } } else { revert("The DAO is not accepting new stakes in the current state."); } } // Function for a user to withdraw their stake function withdraw(address payable account) public onlyRole(MEMBER) { require( currentState != daoState.STAKING, "The DAO is not in the correct state to withdraw." ); if (currentState == daoState.REVOKING) { bool result = execute_revoke(); require(result, "Schedule revoke delay is not finished yet."); } if ( currentState == daoState.REVOKED || currentState == daoState.COLLECTING ) { //Sanity checks if (staking.isDelegator(address(this))) { revert("The DAO is in an inconsistent state."); } require(totalStake != 0, "Cannot divide by zero."); //Calculate the withdrawal amount including staking rewards uint amount = address(this).balance * memberStakes[msg.sender] / totalStake; require( check_free_balance() >= amount, "Not enough free balance for withdrawal." ); Address.sendValue(account, amount); totalStake = totalStake - (memberStakes[msg.sender]); memberStakes[msg.sender] = 0; emit withdrawal(msg.sender, account, amount); } } // Schedule revoke, admin only function schedule_revoke() public onlyRole(DEFAULT_ADMIN_ROLE) { require( currentState == daoState.STAKING, "The DAO is not in the correct state to schedule a revoke." ); staking.scheduleRevokeDelegation(target); currentState = daoState.REVOKING; } // Try to execute the revoke, returns true if it succeeds, false if it doesn't function execute_revoke() internal onlyRole(MEMBER) returns (bool) { require( currentState == daoState.REVOKING, "The DAO is not in the correct state to execute a revoke." ); staking.executeDelegationRequest(address(this), target); if (staking.isDelegator(address(this))) { return false; } else { currentState = daoState.REVOKED; return true; } } // Check how much free balance the DAO currently has. It should be the staking rewards if the DAO state is anything other than REVOKED or COLLECTING. function check_free_balance() public view onlyRole(MEMBER) returns (uint256) { return address(this).balance; } // Change the collator target, admin only function change_target( address newCollator ) public onlyRole(DEFAULT_ADMIN_ROLE) { require( currentState == daoState.REVOKED || currentState == daoState.COLLECTING, "The DAO is not in the correct state to change staking target." ); target = newCollator; } // Reset the DAO state back to COLLECTING, admin only function reset_dao() public onlyRole(DEFAULT_ADMIN_ROLE) { currentState = daoState.COLLECTING; } // Override _setupRole to use grantRole as _setupRole does not exist in AccessControl anymore function _setupRole(bytes32 role, address account) internal virtual { grantRole(role, account); } }
-
Create a new file called
StakingInterface.sol
in thecontracts
directorytouch contracts/StakingInterface.sol
-
Copy and paste the contents of StakingInterface.sol into
StakingInterface.sol
StakingInterface.sol
// SPDX-License-Identifier: GPL-3.0-only pragma solidity >=0.8.3; /// @dev The ParachainStaking contract's address. address constant PARACHAIN_STAKING_ADDRESS = 0x0000000000000000000000000000000000000800; /// @dev The ParachainStaking contract's instance. ParachainStaking constant PARACHAIN_STAKING_CONTRACT = ParachainStaking( PARACHAIN_STAKING_ADDRESS ); /// @author The Moonbeam Team /// @title Pallet Parachain Staking Interface /// @dev The interface through which Solidity contracts will interact with Parachain Staking /// We follow this same interface including four-byte function selectors, in the precompile that /// wraps the pallet /// @custom:address 0x0000000000000000000000000000000000000800 interface ParachainStaking { /// @dev Check whether the specified address is currently a staking delegator /// @custom:selector fd8ab482 /// @param delegator the address that we want to confirm is a delegator /// @return A boolean confirming whether the address is a delegator function isDelegator(address delegator) external view returns (bool); /// @dev Check whether the specified address is currently a collator candidate /// @custom:selector d51b9e93 /// @param candidate the address that we want to confirm is a collator andidate /// @return A boolean confirming whether the address is a collator candidate function isCandidate(address candidate) external view returns (bool); /// @dev Check whether the specifies address is currently a part of the active set /// @custom:selector 740d7d2a /// @param candidate the address that we want to confirm is a part of the active set /// @return A boolean confirming whether the address is a part of the active set function isSelectedCandidate( address candidate ) external view returns (bool); /// @dev Total points awarded to all collators in a particular round /// @custom:selector 9799b4e7 /// @param round the round for which we are querying the points total /// @return The total points awarded to all collators in the round function points(uint256 round) external view returns (uint256); /// @dev Total points awarded to a specific collator in a particular round. /// A value of `0` may signify that no blocks were produced or that the storage for that round has been removed /// @custom:selector bfea66ac /// @param round the round for which we are querying the awarded points /// @param candidate The candidate to whom the points are awarded /// @return The total points awarded to the collator for the provided round function awardedPoints( uint32 round, address candidate ) external view returns (uint32); /// @dev The amount delegated in support of the candidate by the delegator /// @custom:selector a73e51bc /// @param delegator Who made this delegation /// @param candidate The candidate for which the delegation is in support of /// @return The amount of the delegation in support of the candidate by the delegator function delegationAmount( address delegator, address candidate ) external view returns (uint256); /// @dev Whether the delegation is in the top delegations /// @custom:selector 91cc8657 /// @param delegator Who made this delegation /// @param candidate The candidate for which the delegation is in support of /// @return If delegation is in top delegations (is counted) function isInTopDelegations( address delegator, address candidate ) external view returns (bool); /// @dev Get the minimum delegation amount /// @custom:selector 02985992 /// @return The minimum delegation amount function minDelegation() external view returns (uint256); /// @dev Get the CandidateCount weight hint /// @custom:selector a9a981a3 /// @return The CandidateCount weight hint function candidateCount() external view returns (uint256); /// @dev Get the current round number /// @custom:selector 146ca531 /// @return The current round number function round() external view returns (uint256); /// @dev Get the CandidateDelegationCount weight hint /// @custom:selector 2ec087eb /// @param candidate The address for which we are querying the nomination count /// @return The number of nominations backing the collator function candidateDelegationCount( address candidate ) external view returns (uint32); /// @dev Get the CandidateAutoCompoundingDelegationCount weight hint /// @custom:selector 905f0806 /// @param candidate The address for which we are querying the auto compounding /// delegation count /// @return The number of auto compounding delegations function candidateAutoCompoundingDelegationCount( address candidate ) external view returns (uint32); /// @dev Get the DelegatorDelegationCount weight hint /// @custom:selector 067ec822 /// @param delegator The address for which we are querying the delegation count /// @return The number of delegations made by the delegator function delegatorDelegationCount( address delegator ) external view returns (uint256); /// @dev Get the selected candidates for the current round /// @custom:selector bcf868a6 /// @return The selected candidate accounts function selectedCandidates() external view returns (address[] memory); /// @dev Whether there exists a pending request for a delegation made by a delegator /// @custom:selector 3b16def8 /// @param delegator the delegator that made the delegation /// @param candidate the candidate for which the delegation was made /// @return Whether a pending request exists for such delegation function delegationRequestIsPending( address delegator, address candidate ) external view returns (bool); /// @dev Whether there exists a pending exit for candidate /// @custom:selector 43443682 /// @param candidate the candidate for which the exit request was made /// @return Whether a pending request exists for such delegation function candidateExitIsPending( address candidate ) external view returns (bool); /// @dev Whether there exists a pending bond less request made by a candidate /// @custom:selector d0deec11 /// @param candidate the candidate which made the request /// @return Whether a pending bond less request was made by the candidate function candidateRequestIsPending( address candidate ) external view returns (bool); /// @dev Returns the percent value of auto-compound set for a delegation /// @custom:selector b4d4c7fd /// @param delegator the delegator that made the delegation /// @param candidate the candidate for which the delegation was made /// @return Percent of rewarded amount that is auto-compounded on each payout function delegationAutoCompound( address delegator, address candidate ) external view returns (uint8); /// @dev Join the set of collator candidates /// @custom:selector 1f2f83ad /// @param amount The amount self-bonded by the caller to become a collator candidate /// @param candidateCount The number of candidates in the CandidatePool function joinCandidates(uint256 amount, uint256 candidateCount) external; /// @dev Request to leave the set of collator candidates /// @custom:selector b1a3c1b7 /// @param candidateCount The number of candidates in the CandidatePool function scheduleLeaveCandidates(uint256 candidateCount) external; /// @dev Execute due request to leave the set of collator candidates /// @custom:selector 3867f308 /// @param candidate The candidate address for which the pending exit request will be executed /// @param candidateDelegationCount The number of delegations for the candidate to be revoked function executeLeaveCandidates( address candidate, uint256 candidateDelegationCount ) external; /// @dev Cancel request to leave the set of collator candidates /// @custom:selector 9c76ebb4 /// @param candidateCount The number of candidates in the CandidatePool function cancelLeaveCandidates(uint256 candidateCount) external; /// @dev Temporarily leave the set of collator candidates without unbonding /// @custom:selector a6485ccd function goOffline() external; /// @dev Rejoin the set of collator candidates if previously had called `goOffline` /// @custom:selector 6e5b676b function goOnline() external; /// @dev Request to bond more for collator candidates /// @custom:selector a52c8643 /// @param more The additional amount self-bonded function candidateBondMore(uint256 more) external; /// @dev Request to bond less for collator candidates /// @custom:selector 60744ae0 /// @param less The amount to be subtracted from self-bond and unreserved function scheduleCandidateBondLess(uint256 less) external; /// @dev Execute pending candidate bond request /// @custom:selector 2e290290 /// @param candidate The address for the candidate for which the request will be executed function executeCandidateBondLess(address candidate) external; /// @dev Cancel pending candidate bond request /// @custom:selector b5ad5f07 function cancelCandidateBondLess() external; /// @notice DEPRECATED use delegateWithAutoCompound instead for lower weight and better UX /// @dev Make a delegation in support of a collator candidate /// @custom:selector 829f5ee3 /// @param candidate The address of the supported collator candidate /// @param amount The amount bonded in support of the collator candidate /// @param candidateDelegationCount The number of delegations in support of the candidate /// @param delegatorDelegationCount The number of existing delegations by the caller function delegate( address candidate, uint256 amount, uint256 candidateDelegationCount, uint256 delegatorDelegationCount ) external; /// @dev Make a delegation in support of a collator candidate /// @custom:selector 4b8bc9bf /// @param candidate The address of the supported collator candidate /// @param amount The amount bonded in support of the collator candidate /// @param autoCompound The percent of reward that should be auto-compounded /// @param candidateDelegationCount The number of delegations in support of the candidate /// @param candidateAutoCompoundingDelegationCount The number of auto-compounding delegations /// in support of the candidate /// @param delegatorDelegationCount The number of existing delegations by the caller function delegateWithAutoCompound( address candidate, uint256 amount, uint8 autoCompound, uint256 candidateDelegationCount, uint256 candidateAutoCompoundingDelegationCount, uint256 delegatorDelegationCount ) external; /// @dev Request to revoke an existing delegation /// @custom:selector 1a1c740c /// @param candidate The address of the collator candidate which will no longer be supported function scheduleRevokeDelegation(address candidate) external; /// @dev Bond more for delegators with respect to a specific collator candidate /// @custom:selector 0465135b /// @param candidate The address of the collator candidate for which delegation shall increase /// @param more The amount by which the delegation is increased function delegatorBondMore(address candidate, uint256 more) external; /// @dev Request to bond less for delegators with respect to a specific collator candidate /// @custom:selector c172fd2b /// @param candidate The address of the collator candidate for which delegation shall decrease /// @param less The amount by which the delegation is decreased (upon execution) function scheduleDelegatorBondLess( address candidate, uint256 less ) external; /// @dev Execute pending delegation request (if exists && is due) /// @custom:selector e98c8abe /// @param delegator The address of the delegator /// @param candidate The address of the candidate function executeDelegationRequest( address delegator, address candidate ) external; /// @dev Cancel pending delegation request (already made in support of input by caller) /// @custom:selector c90eee83 /// @param candidate The address of the candidate function cancelDelegationRequest(address candidate) external; /// @dev Sets an auto-compound value for a delegation /// @custom:selector faa1786f /// @param candidate The address of the supported collator candidate /// @param value The percent of reward that should be auto-compounded /// @param candidateAutoCompoundingDelegationCount The number of auto-compounding delegations /// in support of the candidate /// @param delegatorDelegationCount The number of existing delegations by the caller function setAutoCompound( address candidate, uint8 value, uint256 candidateAutoCompoundingDelegationCount, uint256 delegatorDelegationCount ) external; /// @dev Fetch the total staked amount of a delegator, regardless of the /// candidate. /// @custom:selector e6861713 /// @param delegator Address of the delegator. /// @return Total amount of stake. function getDelegatorTotalStaked( address delegator ) external view returns (uint256); /// @dev Fetch the total staked towards a candidate. /// @custom:selector bc5a1043 /// @param candidate Address of the candidate. /// @return Total amount of stake. function getCandidateTotalCounted( address candidate ) external view returns (uint256); }
-
DelegationDAO.sol
relies on a couple of standard OpenZeppelin contracts. Add the library with the following command:npm install @openzeppelin/contracts
Hardhat Configuration File¶
When setting up the hardhat.config.js
file, we'll need to import a few plugins that we'll use throughout this guide. So to get started, we'll need the Hardhat Toolbox plugin, which conveniently bundles together Hardhat plugins that can be used to deploy and interact with contracts using Ethers, test contracts with Mocha and Chai, verify contracts with Etherscan, and more. You can run the following command to install the plugin:
npm install --save-dev @nomicfoundation/hardhat-toolbox
If you're curious about additional Hardhat plugins, here is a complete list of official Hardhat plugins.
For the examples in this guide, you'll need to add your private keys for your two accounts on Moonbase Alpha. Since some of the testing will be done on a development node, you'll also need to add the private keys of two of the prefunded development node accounts, which for this example, we can use Alice and Bob. In addition, you'll add your Moonscan API key, which can be used for both Moonbase Alpha and Moonbeam.
You can modify the `hardhat.config.js` file to use any of the Moonbeam networks:
=== "Moonbeam"
```js
moonbeam: {
url: 'INSERT_RPC_API_ENDPOINT', // Insert your RPC URL here
chainId: 1284, // (hex: 0x504),
accounts: [privateKey]
},
```
=== "Moonriver"
```js
moonriver: {
url: 'INSERT_RPC_API_ENDPOINT', // Insert your RPC URL here
chainId: 1285, // (hex: 0x505),
accounts: [privateKey]
},
```
=== "Moonbase Alpha"
```js
moonbase: {
url: 'https://rpc.api.moonbase.moonbeam.network',
chainId: 1287, // (hex: 0x507),
accounts: [privateKey]
},
```
=== "Moonbeam Dev Node"
```js
dev: {
url: 'http://127.0.0.1:9944',
chainId: 1281, // (hex: 0x501),
accounts: [privateKey]
},
```
- Add your Moonscan API key, which is required for the verification steps we'll be taking later in this tutorial
// 1. Import the Hardhat Toolbox plugin
require('@nomicfoundation/hardhat-toolbox');
require('@nomicfoundation/hardhat-ignition-ethers');
// 2. Create variables for your private keys from your pre-funded Moonbase Alpha
// testing accounts and your Moonscan API key
const privateKey = 'INSERT_PRIVATE_KEY';
const privateKey2 = 'INSERT_ANOTHER_PRIVATE_KEY';
const moonscanAPIKey = 'INSERT_MOONSCAN_API_KEY';
module.exports = {
// 3. Specify the Solidity version
solidity: '0.8.20',
networks: {
// 4. Add the Moonbase Alpha network specification
moonbase: {
url: 'https://rpc.api.moonbase.moonbeam.network',
chainId: 1287, // 0x507 in hex
accounts: [privateKey, privateKey2],
},
dev: {
url: 'http://127.0.0.1:9944',
chainId: 1281, // 0x501 in hex
accounts: [
'0x5fb92d6e98884f76de468fa3f6278f8807c48bebc13595d45af5bdc4da702133', // Alice's PK
'0x8075991ce870b93a8870eca0c0f91913d12f47948ca0fd25b49c6fa7cdbeee8b', // Bob's PK
],
},
moonbeam: {
url: 'https://rpc.api.moonbeam.network', // Or insert your own RPC URL here
chainId: 1284, // 0x504 in hex
accounts: [privateKey, privateKey2],
},
},
// 5. Set up your Moonscan API key for contract verification
// Moonbeam and Moonbase Alpha Moonscan use the same API key
etherscan: {
apiKey: {
moonbaseAlpha: moonscanAPIKey, // Moonbase Moonscan API Key
moonbeam: moonscanAPIKey, // Moonbeam Moonscan API Key
},
},
};
Note
Any real funds sent to the Alice and Bob development accounts will be lost immediately. Take precautions to never send MainNet funds to exposed development accounts.
You're now ready to move on to compilation and testing.
Compiling the Contract¶
To compile the contract you can simply run:
npx hardhat compile
After compilation, an artifacts
directory is created: it holds the bytecode and metadata of the contract, which are .json
files. Adding this directory to your .gitignore
is a good idea.
Testing¶
A robust smart contract development workflow is complete with a testing suite. Hardhat has a number of tools that make it easy to write and run tests. In this section, you'll learn the basics of testing your smart contracts and some more advanced techniques.
Hardhat tests are typically written with Mocha and Chai. Mocha is a JavaScript testing framework and Chai is a BDD/TDD JavaScript assertion library. BDD/TDD stands for behavior and test-driven development respectively. Effective BDD/TDD necessitates writing your tests before writing your smart contract code. The structure of this tutorial doesn't strictly follow these guidelines, but you may wish to adopt these principles in your development workflow. Hardhat recommends using Hardhat Toolbox, a plugin that bundles everything you need to get started with Hardhat, including Mocha and Chai.
Because we will initially be running our tests on a local Moonbeam node, we need to specify Alice's address as the address of our target collator (Alice's account is the only collator for a local development node):
0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac
If instead you prefer to run your tests against Moonbase Alpha, you can choose the below collator, or any other collator on Moonbase Alpha you would like the DAO to delegate to:
0x12E7BCCA9b1B15f33585b5fc898B967149BDb9a5
Configuring the Test File¶
To set up your test file, take the following steps:
-
Create a
tests
directorymkdir tests
-
Create a new file called
Dao.js
touch tests/Dao.js
-
Then copy and paste the contents below to set up the initial structure of your test file. Be sure to read the comments, as they can clarify the purpose of each line
// Import Ethers const { ethers } = require('hardhat'); // Import Chai to use its assertion functions here const { expect } = require('chai'); // Indicate Alice's address as the target collator on local development node const targetCollator = '0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac';
Deploying a Staking DAO for Testing¶
Before we can run any test cases we'll need to launch a staking DAO with an initial configuration. Our setup here is relatively simple - we'll be deploying a staking DAO with a single administrator (the deployer) and then adding a new member to the DAO. This simple setup is perfect for demonstration purposes, but it's easy to imagine more complex configurations you'd like to test, such as a scenario with 100 DAO members or one with multiple admins of the DAO.
Mocha's describe
function enables you to organize your tests. Multiple describe
functions can be nested together. It's entirely optional but can be useful, especially in complex projects with many test cases. You can read more about constructing tests and getting started with Mocha on the Mocha docs site.
We'll define a function called deployDao
containing the setup steps for our staking DAO. To configure your test file, add the following snippet:
// The describe function receives the name of a section of your test suite, and a callback. The callback must define the tests of that section. This callback can't be an async function
describe('Dao contract', function () {
let wallet1, wallet2;
before(async function () {
// Get signers we defined in Hardhat config
const signers = await ethers.getSigners();
wallet1 = signers[0];
wallet2 = signers[1];
});
async function deployDao() {
const delegationDaoFactory = await ethers.getContractFactory(
'DelegationDAO',
wallet2
);
// Deploy the staking DAO and wait for the deployment transaction to be confirmed
try {
const deployedDao = await delegationDaoFactory.deploy(
targetCollator,
wallet2.address
);
await deployedDao.waitForDeployment(); // Wait for the transaction to be mined
return { deployedDao };
} catch (error) {
console.error('Failed to deploy contract:', error);
return null; // Return null to indicate failure
}
}
// Insert additional tests here
});
Writing your First Test Cases¶
First, you'll create a subsection called Deployment
to keep the test file organized. This will be nested within the Dao contract
describe function. Next, you'll define your first test case by using the it
Mocha function. This first test checks to see that the staking DAO correctly stores the address of the target collator.
Add the snippet below to the end of your Dao contract
function.
// You can nest calls to create subsections
describe('Deployment', function () {
// Mocha's it function is used to define each of your tests. It receives the test name, and a callback function. If the callback function is async, Mocha will await it. Test case to check that the correct target collator is stored
it('should store the correct target collator in the DAO', async function () {
const deployment = await deployDao();
if (!deployment || !deployment.deployedDao) {
throw new Error('Deployment failed; DAO contract was not deployed.');
}
const { deployedDao } = deployment;
// The expect function receives a value and wraps it in an assertion object.
// This test will pass if the DAO stored the correct target collator
expect(await deployedDao.getTarget()).to.equal(
'0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac'
);
});
// The following test cases should be added here
});
Now, add another test case. When a staking DAO is launched, it shouldn't have any funds. This test verifies that is indeed the case. Go ahead and add the following test case to your Dao.js
file:
// Test case to check that the DAO has 0 funds at inception
it('should initially have 0 funds in the DAO', async function () {
const { deployedDao } = await deployDao();
// This test will pass if the DAO has no funds as expected before any contributions
expect(await deployedDao.totalStake()).to.equal(0);
});
Function Reverts¶
Now, you'll implement a more complex test case with a slightly different architecture. In prior examples, you've verified that a function returns an expected value. In this one, you'll be verifying that a function reverts. You'll also change the caller's address to test an admin-only function.
In the staking DAO contract, only admins are authorized to add new members to the DAO. One could write a test that checks to see if the admin is authorized to add new members but a more important test is to ensure that non-admins can't add new members. To run this test case under a different account, you will ask for another address when you call ethers.getSigners()
and specify the caller in the assertion with connect(member1)
. Finally, after the function call you'll append .to.be.reverted
to indicate that the test case is successful if the function reverts. And if it doesn't revert, it's a failed test!
// Test case to check that non-admins cannot grant membership
it('should not allow non-admins to grant membership', async function () {
const { deployedDao } = await deployDao();
// Connect the non-admin wallet to the deployed contract
const deployedDaoConnected = deployedDao.connect(wallet1);
const tx = deployedDaoConnected.grant_member(
'0x0000000000000000000000000000000000000000'
);
// Check that the transaction reverts, not specifying any particular reason
await expect(tx).to.be.reverted;
});
Signing Transactions from Other Accounts¶
For this example, you'll verify whether the newly added DAO member can call the check_free_balance()
function of staking DAO, which has an access modifier such that only members can access it.
// Test case to check that members can access member only functions
it('should only allow members to access member-only functions', async function () {
const { deployedDao } = await deployDao();
// Connect the wallet1 to the deployed contract and grant membership
const deployedDaoConnected = deployedDao.connect(wallet2);
const grantTx = await deployedDaoConnected.grant_member(wallet1.address);
await grantTx.wait();
// Check the free balance using the member's credentials
const checkTx = deployedDaoConnected.check_free_balance();
// Since check_free_balance() does not modify state, we expect it not to be reverted and check the balance
await expect(checkTx).to.not.be.reverted;
expect(await checkTx).to.equal(0);
});
And that's it! You're now ready to run your tests!
Running your Tests¶
If you've followed all of the prior sections, your Dao.js
test file should be all set to go.
Dao.js
// Import Ethers
const { ethers } = require('hardhat');
// Import Chai to use its assertion functions here
const { expect } = require('chai');
// Indicate the collator the DAO wants to delegate to
// For Moonbase Local Node, use: 0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac
// For Moonbase Alpha, use: 0x12E7BCCA9b1B15f33585b5fc898B967149BDb9a5
const targetCollator = 'INSERT_COLLATOR_ADDRESS';
// The describe function receives the name of a section of your test suite, and a
// callback. The callback must define the tests of that section. This callback
// can't be an async function
describe('Dao contract', function () {
let wallet1, wallet2;
before(async function () {
// Get signers we defined in Hardhat config
const signers = await ethers.getSigners();
wallet1 = signers[0];
wallet2 = signers[1];
});
async function deployDao() {
const delegationDaoFactory = await ethers.getContractFactory(
'DelegationDAO',
wallet2
);
// Deploy the staking DAO and wait for the deployment transaction to be confirmed
try {
const deployedDao = await delegationDaoFactory.deploy(
targetCollator,
wallet2.address
);
await deployedDao.waitForDeployment(); // Correct way to wait for the transaction to be mined
return { deployedDao };
} catch (error) {
console.error('Failed to deploy contract:', error);
return null; // Return null to indicate failure
}
}
describe('Deployment', function () {
// Test case to check that the correct target collator is stored
it('should store the correct target collator in the DAO', async function () {
const deployment = await deployDao();
if (!deployment || !deployment.deployedDao) {
throw new Error('Deployment failed; DAO contract was not deployed.');
}
const { deployedDao } = deployment;
expect(await deployedDao.getTarget()).to.equal(
'0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac'
);
});
// Test case to check that the DAO has 0 funds at inception
it('should initially have 0 funds in the DAO', async function () {
const { deployedDao } = await deployDao();
expect(await deployedDao.totalStake()).to.equal(0);
});
// Test case to check that non-admins cannot grant membership
it('should not allow non-admins to grant membership', async function () {
const { deployedDao } = await deployDao();
// Connect the non-admin wallet to the deployed contract
const deployedDaoConnected = deployedDao.connect(wallet1);
const tx = deployedDaoConnected.grant_member(
'0x0000000000000000000000000000000000000000'
);
// Check that the transaction reverts, not specifying any particular reason
await expect(tx).to.be.reverted;
});
// Test case to check that members can access member only functions
it('should only allow members to access member-only functions', async function () {
const { deployedDao } = await deployDao();
// Connect the wallet1 to the deployed contract and grant membership
const deployedDaoConnected = deployedDao.connect(wallet2);
const grantTx = await deployedDaoConnected.grant_member(wallet1.address);
await grantTx.wait();
// Check the free balance using the member's credentials
const checkTx = deployedDaoConnected.check_free_balance();
// Since check_free_balance() does not modify state, we expect it not to be reverted and check the balance
await expect(checkTx).to.not.be.reverted;
expect(await checkTx).to.equal(0);
});
});
});
Since our test cases encompass mostly configuration and setup of the staking DAO and don't involve actual delegation actions, we'll be running our tests on a Moonbeam development node (local node). Remember that Alice (0xf24FF3a9CF04c71Dbc94D0b566f7A27B94566cac
) is the only collator on a local development node. You can use the flag --network moonbase
to run the tests using Moonbase Alpha. In that case, ensure your deployer address is sufficiently funded with DEV tokens.
Challenge
Try to create an additional test case that verifies the staking DAO successfully delegates to a collator once minDelegationStk
is met. You must test this on Moonbase Alpha rather than a local development node.
First, make sure that your local Moonbeam node is running by following the instructions for launching a local development node. Take precautions because you could inadvertently send real funds to the Alice and Bob development accounts, resulting in a loss of those funds.
You can run your tests with the following command:
npx hardhat test --network dev tests/Dao.js
If everything was set up correctly, you should see output like the following:
Dao contract Deployment ✅ The DAO should store the correct target collator (1624ms) ✅ The DAO should initially have 0 funds in it ✅ Non-admins should not be able to grant membership (150ms) ✅ DAO members should be able to access member only functions (132ms)
✅ 4 passing (2s)
Deploying to Moonbase Alpha¶
In the following steps, we'll deploy the DelegationDAO
to the Moonbase Alpha TestNet. Before deploying to Moonbase Alpha or Moonbeam, double-check that you're not using the Alice and Bob accounts, which should only be used on a local development node.
As a side note, DelegationDAO
relies on StakingInterface.sol
, which is a Substrate-based offering unique to Moonbeam networks. The Hardhat Network and forked networks are simulated EVM environments which do not include the Substrate-based precompiles like StakingInterface.sol
. Therefore, DelegationDAO
will not work properly if deployed to the local default Hardhat Network or a forked network.
To deploy DelegationDAO
, you'll use Hardhat Ignition, a declarative framework for deploying smart contracts. Hardhat Ignition is designed to make it easy to manage recurring tasks surrounding smart contract deployment and testing. For more information about Hardhat Ignition and its architecture, be sure to check out the Hardhat Ignition docs.
To set up the proper file structure for your Ignition module, create a folder named ignition
and a subdirectory called modules
. Then, add a new file to it called DelegationDao.js
. You can take all three of these steps with the following command:
mkdir ignition ignition/modules && touch ignition/modules/DelegationDao.js
Next, you can write your Hardhat Ignition module. To get started, take the following steps:
- Import the
buildModule
function from the Hardhat Ignition module - Export a module using
buildModule
- Specify the target collator candidate for the DAO to delegate to
- Use the
getAccount
method to select the deployer account - Deploy
DelegationDAO.sol
- Return an object from the module. This makes the
DelegationDao
contract accessible for interaction in Hardhat tests and scripts
When all is said and done your deployment script should look similar to the following:
// 1. Import the required function from the Hardhat Ignition module
const { buildModule } = require('@nomicfoundation/hardhat-ignition/modules');
// 2. Define and export your deployment module using `buildModule`
module.exports = buildModule('DelegationDAOModule', (m) => {
// 3. Specify the target collator address for the DAO
const targetCollator = '0x12E7BCCA9b1B15f33585b5fc898B967149BDb9a5';
// 4. Use the `getAccount` method to select the deployer account
const deployer = m.getAccount(0);
// 5. Deploy the `DelegationDAO` contract
const delegationDao = m.contract(
'DelegationDAO',
[targetCollator, deployer],
{
from: deployer,
}
);
// 6. Return an object from the module including references to deployed contracts, allowing the contract to be accessible for interaction in Hardhat tests and scripts
return { delegationDao };
});
To run the script and deploy the DelegationDAO.sol
contract, use the following command, which requires you to specify the network name as defined in your hardhat.config.js
. If you don't specify a network, Hardhat will deploy the contract to a local Hardhat network by default.
npx hardhat ignition deploy ./ignition/modules/DelegationDao.js --network moonbase --deployment-id INSERT_YOUR_NAME
You'll be prompted to confirm the network you wish to deploy to. After a few seconds after you confirm, the contract is deployed, and you'll see the contract address in the terminal.
✅ Confirm deploy to network moonbase (1287)? … yes Hardhat Ignition 🚀
Deploying [ DelegationDAOModule ]
Batch #1 Executed DelegationDAOModule#DelegationDAO
[ DelegationDAOModule ] successfully deployed 🚀
Deployed Addresses
DelegationDAOModule#DelegationDAO - 0x69c555fE1A8D0916E6dab0629bd7530D4d2Be4D1
Congratulations, your contract is live on Moonbase Alpha! Save the address, as you will use it to interact with this contract instance in the next step.
Verifying Contracts on Moonbase Alpha¶
Contract verification is an essential step of any developer's workflow, particularly in the theoretical example of this staking DAO. Potential participants in the DAO need to be assured that the smart contract works as intended - and verifying the contract allows anyone to observe and analyze the deployed smart contract.
While it's possible to verify smart contracts on the Moonscan website, the Hardhat Etherscan plugin enables us to verify our staking DAO in a faster and easier manner. It's not an exaggeration to say that the plugin dramatically simplifies the contract verification process, especially for projects that include multiple Solidity files or libraries.
Before beginning the contract verification process, you'll need to acquire a Moonscan API Key. Note that Moonbeam and Moonbase Alpha use the same Moonbeam Moonscan API key, whereas you'll need a distinct API key for Moonriver.
To verify the contract, you will run the ignition verify
command and pass the name of your deployment you set in the prior step.
npx hardhat ignition verify INSERT_YOUR_NAME
Note
If you're deploying DelegationDAO.sol
verbatim without any changes, you may get an Already Verified
error because Moonscan automatically recognizes and verifies smart contracts that have matching bytecode. Your contract will still show as verified, so there is nothing else you need to do. However, if you'd prefer to verify your own DelegationDAO.sol
, you can make a small change to the contract (such as changing a comment) and repeating the compilation, deployment and verification steps.
In your terminal, you should see the source code for your contract was successfully submitted for verification. If the verification was successful, you should see Successfully verified contract and there will be a link to the contract code on Moonscan for Moonbase Alpha. If the plugin returns an error, double check that your API key is configured correctly and that you have specified all necessary parameters in the verification command. You can refer to the guide to the Hardhat Etherscan plugin for more information.
Nothing to compile Successfully submitted source code for contract contracts/DelegationDAO.sol:DelegationDAO at 0x5D788B98E4A90F9642352B0b32694998e77cF4d7 for verification on the block explorer. Waiting for verification result...
Successfully verified contract DelegationDAO on Etherscan.
https://moonbase.moonscan.io/address/0x5D788B98E4A90F9642352B0b32694998e77cF4d7#code
Deploying to Production on Moonbeam Mainnet¶
Note
DelegationDAO.sol
is unreviewed and unaudited. It is designed only for demonstration purposes and not intended for production use. It may contain bugs or logic errors that could result in loss of funds.
In the following steps, we'll be deploying the DelegationDAO
contract to the Moonbeam MainNet network. Remember to add the Moonbeam network to your hardhat.config.js
and update the private keys of your accounts on Moonbeam if you haven't done so already. Before deploying DelegationDAO
to Moonbeam, we need to change the address of the target collator, since our target collator on Moonbase Alpha does not exist on Moonbeam. Head to your deploy script and change the target collator to 0x1C86E56007FCBF759348dcF0479596a9857Ba105
or another Moonbeam collator of your choice. Your deployment script, named DelegationDao.js
, should thus look like the following:
// 1. Import the required function from the Hardhat Ignition module
const { buildModule } = require('@nomicfoundation/hardhat-ignition/modules');
// 2. Define and export your deployment module using `buildModule`
module.exports = buildModule('DelegationDAOModule', (m) => {
// 3. Specify the target collator address for the DAO
const targetCollator = '0x1C86E56007FCBF759348dcF0479596a9857Ba105';
// 4. Use the `getAccount` method to select the deployer account
const deployer = m.getAccount(0);
// 5. Deploy the `DelegationDAO` contract
const delegationDao = m.contract(
'DelegationDAO',
[targetCollator, deployer],
{
from: deployer,
}
);
// 6. Return an object from the module including references to deployed contracts, allowing the contract to be accessible for interaction in Hardhat tests and scripts
return { delegationDao };
});
To run the script and deploy the DelegationDAO.sol
contract, use the following command, which requires you to specify the network name as defined in your hardhat.config.js
. If you don't specify a network, Hardhat will deploy the contract to a local Hardhat network by default.
npx hardhat ignition deploy ./ignition/modules/DelegationDao.js --network moonbeam --deployment-id INSERT_YOUR_NAME
You'll be prompted to confirm the network you wish to deploy to. After a few seconds after you confirm, the contract is deployed, and you'll see the contract address in the terminal.
✅ Confirm deploy to network moonbeam (1284)? … yes Hardhat Ignition 🚀
Deploying [ DelegationDAOModule ]
Batch #1 Executed DelegationDAOModule#DelegationDAO
[ DelegationDAOModule ] successfully deployed 🚀
Deployed Addresses
DelegationDAOModule#DelegationDAO - 0x6D895A55F5ba31e582bCEe71cae394266F240e9b
Congratulations, your contract is live on Moonbeam! Save the address, as you will use it to interact with this contract instance in the next step.
Verifying Contracts on Moonbeam¶
In this section, we'll be verifying the contract that was just deployed on Moonbeam. Before beginning the contract verification process, you'll need to acquire a Moonscan API Key. Note that Moonbeam and Moonbase Alpha use the same Moonbeam Moonscan API key, whereas you'll need a distinct API key for Moonriver.
To verify the contract, you will run the ignition verify
command and pass the name of your deployment you set in the prior step.
npx hardhat ignition verify INSERT_YOUR_NAME
Note
If you're deploying DelegationDAO.sol
verbatim without any changes, you may get an Already Verified
error because Moonscan automatically recognizes and verifies smart contracts that have matching bytecode. Your contract will still show as verified, so there is nothing else you need to do. However, if you'd prefer to verify your own DelegationDAO.sol
, you can make a small change to the contract (such as changing a comment) and repeating the compilation, deployment, and verification steps.
In your terminal you should see the source code for your contract was successfully submitted for verification. If the verification was successful, you should see Successfully verified contract and there will be a link to the contract code on Moonbeam Moonscan. If the plugin returns an error, double check that your API key is configured correctly and that you have specified all necessary parameters in the verification command. You can refer to the guide to the Hardhat Etherscan plugin for more information.
Nothing to compile Successfully submitted source code for contract contracts/DelegationDAO.sol:DelegationDAO at 0x6D895A55F5ba31e582bCEe71cae394266F240e9b for verification on the block explorer. Waiting for verification result...
Successfully verified contract DelegationDAO on Etherscan.
https://moonbeam.moonscan.io/address/0x6D895A55F5ba31e582bCEe71cae394266F240e9b#code
And that's it! We covered a lot of ground in this tutorial, but there's more resources available if you'd like to go deeper, including the following:
| Created: November 18, 2024