James Bachini

Intermediate Solidity Tutorial | Building On DeFi Lego Bricks With Hardhat 👷

Intermediate Solidity Tutorial

In this intermediate solidity tutorial I’ll be building, testing and deploying a smart contract to rebalance a digital asset portfolio. The idea is to look at how we can work with external smart contracts to start building our own products on the lego bricks of DeFi.

The Challenge

To create a solidity smart contract to hold and rebalance a portfolio of digital assets similar to a 60/40 crypto portfolio which I’ve talked about in the past.

This will allow funds to be sent to the contract by the owner at which point they can be rebalanced by calling a contract function. This will execute a swap on a decentralised exchange, specifically Uniswap v3.

We will need some price feeds from external oracles and a way to withdraw funds from the vault.

This is an intermediate solidity tutorial which will be relatively fast flowing and will gloss over the many of the solidity basics covered in this introductory tutorial: https://jamesbachini.com/solidity-tutorial/


Intermediate Solidity Tutorial Video

James On YouTube

The Development Environment

I decided to use Hardhat over Truffle for this tutorial as I feel it provides a feature rich framework and I haven’t done a tutorial with it before. You’ll also need NodeJS, Metamask and a Alchemy API key.

npm install hardhat

My hardhat config file looks like this:-

require("@nomiclabs/hardhat-waffle");
require("@nomiclabs/hardhat-etherscan");

const ethers = require('ethers');
const credentials = require('./credentials.js');

/**
 * @type import('hardhat/config').HardhatUserConfig
 */
module.exports = {
  solidity: "0.8.4",
  networks: {
    kovan: {
      url: `https://eth-kovan.alchemyapi.io/v2/${credentials.alchemy}`,
      accounts: [credentials.privateKey],
    },
    local: {
      url: `http://127.0.0.1:8545/`,
      accounts: [credentials.privateKey],
    },
  },
  etherscan: {
    apiKey: credentials.etherscan
  }
};

All pretty standard, note I’m storing the testnet private key (with funds in) and the alchemy API keys in a file called credentials.js which isn’t included in the GitHub repository.

Speaking of which the full code that we will be going through is here:- https://github.com/jamesbachini/myVault

We can clone this using the following command:

git clone https://github.com/jamesbachini/myVault.git

QuickStart Guide

The following commands will build, test and deploy the contracts.

git clone https://github.com/jamesbachini/myVault.git
cd myVault
mv credentials-example.js credentials.js
code credentials.js (Enter Kovan testnet wallet address with ETH funds and Alchemy/Etherscan API Keys)
npm install
npx hardhat compile
npx hardhat node --fork https://eth-kovan.alchemyapi.io/v2/YourAlchemyAPIKeyHere
npx hardhat test --network local
npx hardhat run scripts/deploy.js --network kovan

If it doesn’t work or doesn’t make sense, read on.


Solidity Code Tutorial

We start by defining a license and solidity version. Note that we are using version 8 and above. Solidity v8 and above is significant from a security perspective as the team introduced a series of checks to prevent integer overflows. This is the reason why I’m not using safemath libraries throughout the code.

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

We then go on to import some libraries from OpenZeppelin and UniswapV3.

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import '@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol';
import '@uniswap/v3-periphery/contracts/interfaces/IQuoter.sol';

The final thing before getting stuck into the contract is we are going to need a couple of interfaces. One for a chainlink oracle to get the price of Ethereum, one to return any left over ETH from Uniswap transactions and the final interface is to add a deposit function to the standard ERC20 interface for converting ETH > WETH.

// EACAggregatorProxy is used for chainlink oracle
interface EACAggregatorProxy {
  function latestAnswer() external view returns (int256);
}

// Uniswap v3 interface
interface IUniswapRouter is ISwapRouter {
  function refundETH() external payable;
}

// Add deposit function for WETH
interface DepositableERC20 is IERC20 {
  function deposit() external payable;
}

Note that we can find out how to declare functions for interfaces on external contracts using the verified code in etherscan.

intermediate solidity tutorial

The contract is then defined and some addresses are hardcoded into the contract. These could also be provided to the constructor function which would be cleaner when moving to mainnet but in this example the code isn’t going anywhere near mainnet so hardcoding them into the contract is fine.

contract myVault {
  uint public version = 1;

  /* Kovan Addresses */
  address public daiAddress = 0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa;
  address public wethAddress = 0xd0A1E359811322d97991E03f863a0C30C2cF029C;
  address public uinswapV3QuoterAddress = 0xb27308f9F90D607463bb33eA1BeBb41C27CE5AB6;
  address public uinswapV3RouterAddress = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
  address public chainLinkETHUSDAddress = 0x9326BFA02ADD2366b30bacB125260Af641031331;

You can use Metamask and Etherscan to find most of these addresses. Just carry out a transaction on the testnet version of Uniswap v3 and then follow the breadcrumbs from transaction in Etherscan. There’s a big list of Chainlink data feed addresses here: https://docs.chain.link/docs/ethereum-addresses/

Note we are using WETH here which is a wrapped version of ETH. Because ETH is the native coin/token of the Ethereum mainnet it doesn’t behave in the same way as an ERC20 token. Therefore wrapped ETH is just an ERC20 token contract where you can deposit ETH and get WETH, a compatible ERC20 pegged 1:1 with ETH, in return.

The next step is to define some state variables.

uint public ethPrice = 0;
uint public usdTargetPercentage = 40;
uint public usdDividendPercentage = 25; // 25% of 40% = 10% Annual Drawdown
uint private dividendFrequency = 5 minutes; // change to 1 years for production
uint public nextDividendTS;
address public owner;

And then some interfaces and a basic logging event.

using SafeERC20 for IERC20;
using SafeERC20 for DepositableERC20;

IERC20 daiToken = IERC20(daiAddress);
DepositableERC20 wethToken = DepositableERC20(wethAddress);
IQuoter quoter = IQuoter(uinswapV3QuoterAddress);
IUniswapRouter uniswapRouter = IUniswapRouter (uinswapV3RouterAddress);

event myVaultLog(string msg, uint ref);

The code above sets up an a SafeERC20 interface for both daiToken and wethToken. Note that the WETH interface is setup using DepositableERC20 interface we created earlier with the extra deposit function. We then setup a quoter interface for getting price data from Uniswap and a uniswapRouter interface to do swaps.

Let’s now create a constructor function which will only fire once while the contract is being deployed.

constructor() {
  console.log('Deploying myVault Version:', version);
  nextDividendTS = block.timestamp + dividendFrequency;
  owner = msg.sender;
}

Note that we set two variables in the constructor function

  • nextDividendTS will be future date at which a withdrawal can take place. Measured as a Unix timestamp which is the number of seconds since the 1st January 1970
  • owner we will set the owner address to the address that deployed the contract and paid for the gas fees. This will be the same address that is in credentials.js

I want to be able to get the account balance of both WETH and DAI so let’s create functions for that.

function getDaiBalance() public view returns(uint) {
  return daiToken.balanceOf(address(this));
}

function getWethBalance() public view returns(uint) {
  return wethToken.balanceOf(address(this));
}

I also want to be able to get the total USD value of the account (which will require a ETH price oracle which hasn’t been set up yet).

function getTotalBalance() public view returns(uint) {
  require(ethPrice > 0, 'ETH price has not been set');
  uint daiBalance = getDaiBalance();
  uint wethBalance = getWethBalance();
  uint wethUSD = wethBalance * ethPrice; // assumes both assets have 18 decimals
  uint totalBalance = wethUSD + daiBalance;
  return totalBalance;
}

This is the first example of a require statement which checks to make sure we have set the ethPrice using an oracle service before getTotalBalance() is called.

So let’s create a couple of different functions to interact with oracle data feeds. The first will use Uniswaps quoter interface to get a price directly from the decentralised exchange. The second will use the Chainlink price data feed which simply quotes a USD value for ETH.

function updateEthPriceUniswap() public returns(uint) {
  uint ethPriceRaw = quoter.quoteExactOutputSingle(daiAddress,wethAddress,3000,100000,0);
  ethPrice = ethPriceRaw / 100000;
  return ethPrice;
}

function updateEthPriceChainlink() public returns(uint) {
  int256 chainLinkEthPrice = EACAggregatorProxy(chainLinkETHUSDAddress).latestAnswer();
  ethPrice = uint(chainLinkEthPrice / 100000000);
  return ethPrice;
}

It’s quite useful on testnet to have two different price oracles because the lack of arbitrage on testnet DEX’s means these will provide totally different answers which is great for testing the effects of price movements.

We now want to create a function to swap DAI for WETH using Uniswap V3’s exactInputSingle function.

function buyWeth(uint amountUSD) internal {
  uint256 deadline = block.timestamp + 15;
  uint24 fee = 3000;
  address recipient = address(this);
  uint256 amountIn = amountUSD; // includes 18 decimals
  uint256 amountOutMinimum = 0;
  uint160 sqrtPriceLimitX96 = 0;
  emit myVaultLog('amountIn', amountIn);
  require(daiToken.approve(address(uinswapV3RouterAddress), amountIn), 'DAI approve failed');
  ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams(
    daiAddress,
    wethAddress,
    fee,
    recipient,
    deadline,
    amountIn,
    amountOutMinimum,
    sqrtPriceLimitX96
  );
  uniswapRouter.exactInputSingle(params);
  uniswapRouter.refundETH();
}

The deadline is set to 15 seconds from the block timestamp. The fee is set to the standard 0.3% pool. Amount in includes the 18 decimals for DAI and is a USD amount of ETH to buy. There’s no minimum return set which does open it up to slippage and front running, again not an issue on Kovan testnet.

We then emit a log entry for the amountIn value, mainly for debugging.

The code goes on to approve the Uniswap router contract address to spend the exact amountIn of DAI tokens. Then finally we execute the swap.

The next code snippet does the same thign but sells WETH for DAI using the exactOutputSingle function.

function sellWeth(uint amountUSD) internal {
  uint256 deadline = block.timestamp + 15;
  uint24 fee = 3000;
  address recipient = address(this);
  uint256 amountOut = amountUSD; // includes 18 decimals
  uint256 amountInMaximum = 10 ** 28 ;
  uint160 sqrtPriceLimitX96 = 0;
  require(wethToken.approve(address(uinswapV3RouterAddress), amountOut), 'WETH approve failed');
  ISwapRouter.ExactOutputSingleParams memory params = ISwapRouter.ExactOutputSingleParams(
    wethAddress,
    daiAddress,
    fee,
    recipient,
    deadline,
    amountOut,
    amountInMaximum,
    sqrtPriceLimitX96
  );
  uniswapRouter.exactOutputSingle(params);
  uniswapRouter.refundETH();
}

Now we have these buy/sell functions in place we can create a function to rebalance the portfolio to the target 60/40 percentages.

function rebalance() public {
  require(msg.sender == owner, "Only the owner can rebalance their account");
  uint usdBalance = getDaiBalance();
  uint totalBalance = getTotalBalance();
  uint usdBalancePercentage = 100 * usdBalance / totalBalance;
  emit myVaultLog('usdBalancePercentage', usdBalancePercentage);
  if (usdBalancePercentage < usdTargetPercentage) {
    uint amountToSell = totalBalance / 100 * (usdTargetPercentage - usdBalancePercentage);
    emit myVaultLog('amountToSell', amountToSell);
    require (amountToSell > 0, "Nothing to sell");
    sellWeth(amountToSell);
  } else {
    uint amountToBuy = totalBalance / 100 * (usdBalancePercentage - usdTargetPercentage);
    emit myVaultLog('amountToBuy', amountToBuy);
    require (amountToBuy > 0, "Nothing to buy");
    buyWeth(amountToBuy);
  }
}

Note when calculating percentages we use a back to front formula to avoid decimals due to the unsigned integer type declaration. Integers can only handle whole numbers.

I’m also going to create a function to drawdown a 10% annual dividend from the strategy. This would be useful if it was being set up as a perpetual trust fund or charity contribution portfolio.

function annualDividend() public {
  require(msg.sender == owner, "Only the owner can drawdown their account");
  require(block.timestamp > nextDividendTS, 'Dividend is not yet due');
  uint balance = getDaiBalance();
  uint amount = (balance * usdDividendPercentage) / 100;
  daiToken.safeTransfer(owner, amount);
  nextDividendTS = block.timestamp + dividendFrequency;
}

The function allows the owner to withdraw 25% of the DAI balance. The daiToken.safeTransfer() method is used to send ERC20 funds to the owners wallet. We use the block.timestamp variable to update when the next dividend is due.

For testing purposes I also want a way to close the account and remove all funds.

function closeAccount() public {
  require(msg.sender == owner, "Only the owner can close their account");
  uint daiBalance = getDaiBalance();
  if (daiBalance > 0) {
    daiToken.safeTransfer(owner, daiBalance);
  }
  uint wethBalance = getWethBalance();
  if (wethBalance > 0) {
    wethToken.safeTransfer(owner, wethBalance);
  }
}

This transfers both DAI and WETH balances to the owner.

The final thing we want to do is to allow for ETH to be sent to the contract and converted to WETH.

receive() external payable {

}

function wrapETH() public {
  require(msg.sender == owner, "Only the owner can convert ETH to WETH");
  uint ethBalance = address(this).balance;
  require(ethBalance > 0, "No ETH available to wrap");
  emit myVaultLog('wrapETH', ethBalance);
  wethToken.deposit{ value: ethBalance }();
}

}

So we have a default payable function which will accept ETH to the contract but wont do anything with it. The reason we don’t call wrapETH directly is because it would break most ETH transfers by blowing out the gas limit. When sending ETH via metamask for example it sets a pretty tight gas limit which doesn’t allow the contract to do much in the same transaction.

At the end of the wrapETH() function we interact with the wethToken’s custom deposit function to wrap the entire ETH balance to WETH.

We finally close the contract brackets and that’s a wrap.

If you want to review the entire contract code it’s available here:-

https://github.com/jamesbachini/myVault/blob/main/contracts/myVault.sol


Testing Solidity Smart Contracts

Now let’s write some tests using hardhat and chai.

const hre = require('hardhat');
const assert = require('chai').assert;

describe('myVault', () => {
  let myVault;

  beforeEach(async function () {
    const contractName = 'myVault';
    await hre.run("compile");
    const smartContract = await hre.ethers.getContractFactory(contractName);
    myVault = await smartContract.deploy();
    await myVault.deployed();
    console.log(`${contractName} deployed to: ${myVault.address}`);
  });

  it('Should return the correct version', async () => {
    const version = await myVault.version();
    assert.equal(version,1);
  });
});

This is the most simple test we can write which checks that the public variable version is set to 1. We can run this by using the following command.

npx hardhat test

A lot of our contract functions are calling external functions from other contracts. To test these we will either need to deploy to the Kovan network and test there (slow) or we can fork the Kovan networks state locally.

npx hardhat node --fork https://eth-kovan.alchemyapi.io/v2/AlchemyAPIkeyHere

This will set up a local node which is running a independent fork of the kovan test network.

We can now expand tests to call external contract functions

  it('Should return zero DAI balance', async () => {
    const daiBalance = await myVault.getDaiBalance();
    assert.equal(daiBalance,0);
  });

This test will contact the external daiToken contract at 0x4F96Fe3b7A6Cf9725f59d353F723c1bDb64CA6Aa and check the balance of our contract address. Let’s test this using the local environment we setup in hardhat config which connects to the local node we are running.

npx hardhat test --network local
Hardhat Solidity Tests Kovan Fork

If we were going to deploy this to mainnet we would need to write unit tests for each function and create some widre contract functionality tests. Hardhat excels at advanced scripting methods where we can move funds around and call different contracts etc.

Here’s a more advanced example test where we can send 0.01 ETH from the owner account to the contract, then wrap to WETH, then update the ETH price using the Uniswap oracle and rebalance the portfolio before checking for a DAI balance is above zero.

it('Should Rebalance The Portfolio ', async () => {
  const accounts = await hre.ethers.getSigners();
  const owner = accounts[0];
  console.log('Transfering ETH From Owner Address', owner.address);
  await owner.sendTransaction({
    to: myVault.address,
    value: ethers.utils.parseEther('0.01'),
  });
  await myVault.wrapETH();
  await myVault.updateEthPriceUniswap();
  await myVault.rebalance();
  const daiBalance = await myVault.getDaiBalance();
  console.log('Rebalanced DAI Balance',daiBalance);
  assert.isAbove(daiBalance,0);
});

Deploying Using Hardhat

Once we have some good tests in place we are ready to deploy to the external Kovan testnet and start poking around with it in etherscan. One really nice feature of Hardhat is that we can verify the the contract on Etherscan from within the deployment script.

const hre = require('hardhat');
const fs = require('fs');

async function main() {
  const contractName = 'myVault';
  await hre.run("compile");
  const smartContract = await hre.ethers.getContractFactory(contractName);
  const myVault = await smartContract.deploy();
  await myVault.deployed();
  console.log(`${contractName} deployed to: ${myVault.address}`);

  const contractArtifacts = await artifacts.readArtifactSync(contractName);
  fs.writeFileSync('./artifacts/contractArtifacts.json',  JSON.stringify(contractArtifacts, null, 2));

  await hre.run("verify:verify", {
    address: myVault.address,
    //constructorArguments: [],
  });
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

The script starts by compiling the contract. It then deploys it to the Kovan network and logs the new contract address. We then write the contract artifacts to a contractArtifacts.json file which will come in useful when doing front-end work. Finally we verify the contract on Etherscan.

We can the run this script using the following command

npx hardhat run scripts/deploy.js --network kovan

We can then use then wait for it to verify the contract on Etherscan and output a link to:

https://kovan.etherscan.io/address/0x9e87D719Ad4304731915C5bc5D2304D38E618b7D#code

Where we can interact with the contract using the owners wallet in metamask.

Etherscan Deployed Solidity Contract

To deploy to the Ethereum mainnet or any layer 2 or EVM compatible side chain we can simply add a network in the hardhat config and change the addresses. Note that the contract address for the DAI token or the Uniswap router will be different on each network.

Solidity Security Checks

We would also want to carry out a lot of security checks and ideally have the code audited by a 3rd party before trusting it for financial transactions. A tool called slither can come in quite handy for auditing for simple vulnerabilities. It’s a bit like ESLINT for solidity.

I’ve only ever been able to get it to run on Linux and use Ubuntu on WSL, installing via the following commands in a linux shell:-

sudo add-apt-repository ppa:ethereum/ethereum
sudo apt-get update
sudo apt install solc
sudo pip3 install slither-analyzer
slither
cd /mnt/c/shareddocs/jamesbachini/code/myVault/
sudo npm install
sudo slither .
Here are some of the many issues highlighted in the myVault smart contract by Slither.
Many of these are false positives and issues with imported 3rd party contracts but they’d all need to be checked before deploying to mainnet.

We could take this further and setup a fuzzer like Echidna to brute force contract functions with unusual data. Ideally however, if budgets allow, it is beneficial to get a 3rd party security auditor to look over the code and highlight complex issues that may have been missed by the original developers.


Get The Blockchain Sector Newsletter, binge the YouTube channel and connect with me on Twitter

The Blockchain Sector newsletter goes out a few times a month when there is breaking news or interesting developments to discuss. All the content I produce is free, if you’d like to help please share this content on social media.

Thank you.

James Bachini

Disclaimer: Not a financial advisor, not financial advice. The content I create is to document my journey and for educational and entertainment purposes only. It is not under any circumstances investment advice. I am not an investment or trading professional and am learning myself while still making plenty of mistakes along the way. Any code published is experimental and not production ready to be used for financial transactions. Do your own research and do not play with funds you do not want to lose.