Ethereum development, while still very nascent in feel, has come a long way. When I started developing Solidity smart contracts and Ethereum dapps in 2017, Truffle and Web3.js were the industry standard. These are great tools and I have tons of respect for the people that built them. However, anyone who has used them has dealt with bugs and sometimes poor developer experience. There are a few new tools out there that have clearly been inspired by these first sets of tools and made the developer process much better.
I've been very focused on the Layer 2 side of things at Connext and building the off-chain bits, so I haven't dove into Solidity dev in some time.
I recently participated in a hackathon where I got to dive back in and see the current state of things. I found a lot of cool tools, but not much in the way of docs on how to get things working together. I decided to write up my findings and create a project that can be a starting point for anyone that wants to build and test smart contracts and dapps.
Clone the Starter Kit repo and you'll be fully set up to develop, compile, test and deploy in a full-featured Typescript dev environment!
Buidler bills itself as a "task runner for Ethereum smart contract developers". In practice, this means that the tool will help you bootstrap your Solidity project with a template and give you all the scaffolding needed to test out your smart contracts and ultimately deploy onto the Ethereum blockchain. Previously, it was standard procedure to use Truffle's
init
, compile
, test
, and migrate
features to bootstrap your Solidity projects. A killer feature Buidler touts is stack traces when your Solidity contracts revert 😱!Ethers.js is a Javascript SDK for interacting with the Ethereum blockchain. I used Web3.js exclusively for a long time when I started Solidity development. When I tried Ethers for the first time, I was blown away by how easy it was to get set up and how nice the API is. I urge anyone who is used to working with Web3.js to give Ethers a try. It has all the necessary functions for working with wallets, accounts, and contracts, and it also has some neat utilities such as ABICoder, HDNode, BigNumber, and various formatting utilities for hex strings, ether units, and Etherum addresses.
Ethereum Waffle is a lightweight test runner for Ethereum smart contracts. It has some really nice testing utils built in like Chai matchers for Ethereum addresses, hashes, and BigNumbers, it's Typescript native, and plays really nicely with Ethers.
Typescript has been all the rage lately, and for good reason. For me, the absolute gamechanger with Typescript is the IDE integration which gives you autocomplete for all class properties, object keys, function parameters, etc. I can't ever go back to coding vanilla Javascript after familiarizing with Typescript.
The nice thing about all the tools I mentioned above is that they all work extremely well with Typescript, and once everything is set up, developer experience is a dream.
Now onto the fun stuff! In an empty folder, create an npm project by running
npm init
. It doesn't really matter what the values are set to for the scope of this exercise.Install Buidler:
$ npm install --save-dev @nomiclabs/buidler
Bootstrap Buidler project:
$ npx buidler
Select the option to "Create an empty buidler.config.js" (we will be using a different stack than the example, so we will create our own).
$ npx buidler
888 d8b 888 888
888 Y8P 888 888
888 888 888
88888b. 888 888 888 .d88888 888 .d88b. 888d888
888 "88b 888 888 888 d88" 888 888 d8P Y8b 888P"
888 888 888 888 888 888 888 888 88888888 888
888 d88P Y88b 888 888 Y88b 888 888 Y8b. 888
88888P" "Y88888 888 "Y88888 888 "Y8888 888
👷 Welcome to Buidler v1.0.1 👷
? What do you want to do? …
Create a sample project
❯ Create an empty buidler.config.js
Quit
Create a few directories to hold your project files:
$ mkdir contracts test scripts
Install the required Typescript dependencies:
$ npm install --save-dev ts-node typescript @types/node @types/mocha
Create a
tsconfig
file in the project root:{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
},
"include": ["./scripts", "./test"],
"files": [
"./buidler.config.ts"
]
}
Rename the Buidler config file and make it typesafe:
mv buidler.config.js buidler.config.ts
import { BuidlerConfig } from "@nomiclabs/buidler/config";
const config: BuidlerConfig = {};
export default config;
Now, we're ready to start writing some code!
Create a very simple Solidity contract called
Counter.sol
in the contracts/
directory (latest Solidity version at the time of writing was 0.5.12
):pragma solidity 0.5.12;
contract Counter {
uint256 count = 0;
event CountedTo(uint256 number);
function countUp() public returns (uint256) {
uint256 newCount = count + 1;
require(newCount > count, "Uint256 overflow");
count = newCount;
emit CountedTo(count);
return count;
}
function countDown() public returns (uint256) {
uint256 newCount = count - 1;
require(newCount < count, "Uint256 underflow");
count = newCount;
emit CountedTo(count);
return count;
}
}
Set the Solidity version in
buidler.config.ts
(autocomplete type annotations!!):Buidler conveniently bundles a compilation task, so compiling is a piece of cake:
$ npx buidler compile
Compiling...
Compiled 1 contract successfully
The Solidity versioning system that Buidler uses is AMAZING. Switching versions is a piece of cake, and Buidler automatically downloads and installs Solidity versions as needed, all you need to do is change it in the config. Huge props to the Buidler team for setting this up!
Now, we will set up our testing environment.
Install Ethers, Waffle, and the Buidler plugin:
$ npm install --save-dev @nomiclabs/buidler-ethers ethers ethereum-waffle chai @types/chai
Add the required type definitions to your
tsconfig.json
:{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"resolveJsonModule": true
},
"include": ["./scripts", "./test"],
"files": [
"./buidler.config.ts",
"node_modules/@nomiclabs/buidler-ethers/src/type-extensions.d.ts",
}
Set up
buidler.config.ts
to use the Ethers plugin:import { BuidlerConfig, usePlugin } from "@nomiclabs/buidler/config";
usePlugin("@nomiclabs/buidler-ethers");
const config: BuidlerConfig = {
solc: {
version: "0.5.12"
}
};
export default config;
TypeChain is a really cool tool that gives you a full typed interface for your smart contracts. Once it's set up we can get type hints for contract functions in Typescript!
As of this writing, Buidler does not have a TypeChain plugin. I'm planning to build one myself soon if someone doesn't do it first!
Begin by installing the library and the Ethers binding:
$ npm install --save-dev typechain typechain-target-ethers
Generate the type files by running the command.
outDir
defines where the generated files will be stored, and the quoted string at the end will pick up any generated contracts in our build
directory:$ ./node_modules/.bin/typechain --target ethers --outDir typechain 'build/*.json'
Now inside the
typechain/
directory, you should see a few files generated, one of which is Counter.d.ts
. That's the main contract types file, and gives us what we need to write type safe tests!Writing tests mostly follows the Waffle syntax with one main difference: the
ethers.provider
object is imported from the "@nomiclabs/buidler"
library instead of the ethereum-waffle
library.Now let's write a test. Create a file called
counter.ts
inside the test/
directory:import { ethers } from "@nomiclabs/buidler";
import chai from "chai";
import { deployContract, getWallets, solidity } from "ethereum-waffle";
import CounterArtifact from "../build/Counter.json";
import { Counter } from "../typechain/Counter"
chai.use(solidity);
const { expect } = chai;
describe("Counter", () => {
// 1
const provider = ethers.provider;
// 2
let [wallet] = getWallets(provider);
// 3
let counter: Counter;
beforeEach(async () => {
counter = await deployContract(wallet, CounterArtifact) as Counter;
const initialCount = await counter.getCount();
// 4
expect(initialCount).to.eq(0);
expect(counter.address).to.properAddress;
});
// 5
it("should count up", async () => {
await counter.countUp();
let count = await counter.getCount();
expect(count).to.eq(1);
await counter.countUp();
count = await counter.getCount();
expect(count).to.eq(2);
});
it("should count down", async () => {
// 6
await counter.countDown();
const count = await counter.getCount();
expect(count).to.eq(0);
});
});
Explanation of numbered lines:
1. Set up
provider
using the one imported from Buidler.2. Get a wallet from the
getWallets
function. Also note, you can destructure any number of wallets from this function, for example:let [wallet1, wallet2, wallet3] = getWallets(provider);
3. Import the Counter type and use it as the type of the variable that gets deployed in the
beforeEach
.4. Waffle has some useful Chai matchers for writing contract tests like BigNumber matchers and Ethereum address matchers. Check them all out here.
5. Simple test to count up and make sure the counter works.
6. Those of you that are paying attention will see that this test will fail. Wait on this to see the real magic of Buidler.
Let's run the tests!
$ npx buidler test
Notice something unusual in the results?
All contracts have already been compiled, skipping compilation.
Counter
✓ should count up (143ms)
1) should count down
1 passing (593ms)
1 failing
1) Counter
should count down:
Error: VM Exception while processing transaction: revert Uint256 underflow
at Counter.countDown (contracts/Counter.sol:24)
It's a STACK TRACE from your Solidity code showing the LINE NUMBER that the revert happened on!!! 😱👻💀 Gone are the days of commenting out contracts line by line to see which revert is triggered.
After testing, the final step in the cycle is to deploy your contracts.
The first step is to add a network config to your
buidler.config.ts
file. We'll use rinkeby for this, but you can add any network (i.e. mainnet) similarly:import { BuidlerConfig, usePlugin } from "@nomiclabs/buidler/config";
import waffleDefaultAccounts from "ethereum-waffle/dist/config/defaultAccounts";
usePlugin("@nomiclabs/buidler-ethers");
const INFURA_API_KEY = "";
const RINKEBY_PRIVATE_KEY = "";
const config: BuidlerConfig = {
solc: {
version: "0.5.12"
},
paths: {
artifacts: "./build"
},
networks: {
buidlerevm: {
accounts: waffleDefaultAccounts.map(acc => ({
balance: acc.balance,
privateKey: acc.secretKey
}))
},
rinkeby: {
url: `https://rinkeby.infura.io/v3/${INFURA_API_KEY}`,
accounts: [RINKEBY_PRIVATE_KEY]
}
}
};
export default config;
I'm using Infura as my Ethereum node endpoint, but any remote endpoint would work. If you haven't done this ever, grab an API key from Infura.
Now, we create a deploy script inside our
scripts/
folder called deploy.ts
:import { ethers } from "@nomiclabs/buidler";
async function main() {
const factory = await ethers.getContract("Counter")
// If we had constructor arguments, they would be passed into deploy()
let contract = await factory.deploy();
// The address the Contract WILL have once mined
console.log(contract.address);
// The transaction that was sent to the network to deploy the Contract
console.log(contract.deployTransaction.hash);
// The contract is NOT deployed yet; we must wait until it is mined
await contract.deployed()
}
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error);
process.exit(1);
});
Super easy stuff!
Now, just run the script and we can see our address and transaction hashes right in the console:
$ npx buidler run --network rinkeby scripts/deploy.ts
All contracts have already been compiled, skipping compilation.
0x01FF454Dd078dC7f3cd0905601d093b17E7B9CD7
0x2ae1444920ed76420fb69c9f2fc914c20956efc2ae05c94ab1ea53f224aa0930
We can go to Etherscan and see that the transaction in fact completed successfully.
There you have it! A full step-by-step guide to setting up a supercharged build, test, deploy environment that's typesafe and makes use of some cool new tools.
To keep everything clean and awesome, let's make some handy NPM scripts. Add the following to your
package.json
:"scripts": {
"build": "npm run compile && npm run typechain",
"compile": "npx buidler compile",
"typechain": "npx buidler compile && typechain --outDir typechain --target ethers 'build/*.json'",
"test": "npx buidler test"
}
The
build
script does both contract compilation and generates TypeChain bindings, and the test
script runs the contract tests.Buidler has a super handy plugin for verifying contracts on Etherscan, which is a task that is more complicated than it seems like it should be. Their tool handles flattening for you, which is very handy for contracts that import other contracts, make use of OpenZeppelin libs, etc.
We can start by installing the plugin:
$ npm install --save-dev @nomiclabs/buidler-etherscan
Then, we add to our tsconfig.json to make sure our Typescript environment knows about this plugin:
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"resolveJsonModule": true
},
"include": ["./scripts", "./test"],
"files": [
"./buidler.config.ts",
"node_modules/@nomiclabs/buidler-ethers/src/type-extensions.d.ts",
"node_modules/@nomiclabs/buidler-etherscan/src/type-extensions.d.ts"
]
}
Next, we add the required configuration to our
buidler.config.ts
(hop over to Etherscan and get an API key from your account page if you haven't yet):import { BuidlerConfig, usePlugin } from "@nomiclabs/buidler/config";
import waffleDefaultAccounts from "ethereum-waffle/dist/config/defaultAccounts";
usePlugin("@nomiclabs/buidler-ethers");
usePlugin("@nomiclabs/buidler-etherscan");
const INFURA_API_KEY = "";
const RINKEBY_PRIVATE_KEY = "";
const ETHERSCAN_API_KEY = "";
const config: BuidlerConfig = {
solc: {
version: "0.5.12"
},
paths: {
artifacts: "./build"
},
networks: {
buidlerevm: {
accounts: waffleDefaultAccounts.map(acc => ({
balance: acc.balance,
privateKey: acc.secretKey
}))
},
rinkeby: {
url: `https://rinkeby.infura.io/v3/${INFURA_API_KEY}`,
accounts: [RINKEBY_PRIVATE_KEY]
}
},
etherscan: {
// The url for the Etherscan API you want to use.
url: "https://api-rinkeby.etherscan.io/api",
// Your API key for Etherscan
// Obtain one at https://etherscan.io/
apiKey: ETHERSCAN_API_KEY
}
};
export default config;
Hopefully we kept our deployed address from the previous step handy, because then we can simply run the built in task that this plugin provides:
$ npx buidler verify-contract --contract-name Counter --address 0xF0E6Ea29799E85fc1A97B7b78382fd034A6d7864
All contracts have already been compiled, skipping compilation.
Successfully submitted contract at 0xF0E6Ea29799E85fc1A97B7b78382fd034A6d7864 for verification on etherscan. Waiting for verification result...
Successfully verified contract on etherscan
Easy as pie! Now, check the contract address on Etherscan, and you can see the full contract source code and read and write the contract directly from the webpage.
Buidler seriously impressed me with its devex throughout my whole time using it. It has a ton of cool features already and they have plans to build a whole bunch more cool stuff. In addition to Solidity stack traces, the team plans to roll out another much needed smart contract debugging feature:
console.log
!I will definitely be following this project closely and contributing to its ecosystem however I can.
Stay tuned for more follow-up posts regarding full-stack dapp development and tooling!