DEV Community

Cover image for Testing Interactions with other Smart Contracts
Austin Vance for Focused Labs

Posted on • Updated on

Testing Interactions with other Smart Contracts

Developing on the blockchain is an incredible experience. The ecosystem is open and permissionless; each project becoming a lego brick in whatever idea a developer has in mind. Because of the open nature of the blockchain, it's not uncommon to have your smart contracts interact with another project’s contracts. It may be a Chainlink Oracle, a Dex like Uniswap, or a Lending platform like the QiDAO, or maybe you interact with all three in a single contract?

But how do you test your contract based on responses and interactions with these external contracts?

There are two ways: you can deploy "mock contracts" or you can use a mocking library. There are tradeoffs, but for this post I am going to focus on using a Smock's mocking library to put external contracts into a place for testing.

Smock depends on Hardhat so you need to have a Hardhat project. For the sake of this post let's write and test a smart contract that can liquidate a loan on the QiDAO.

The QiDAO contracts can be found in their docs and the source can be found on their github.

Specifically we will be using the erc20Stablecoin contract deployed for LINK - 0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72

Our simple liquidation contract looks like this.

contract LoanLiquidator {
  address const vaultAddress = 0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72
  function liquidate(uint256 vaultId) external {
    erc20Stablecoin vault = erc20Stablecoin(vaultAddress);
    require(vault.checkLiquidation(vaultId), "Vault not below liquidation threshold");

    vault.liquidateVault(vaultId);
  }
}
Enter fullscreen mode Exit fullscreen mode

For simplicity let's test the two cases of checkLiquidation as liquidateVault doesn't return anything. First we will test but there is a gotcha. We can get into that later!

describe("LoanLiquidator", () => {
  describe("#liquidate", () => {
    it("should revert if the vault cannot be liquidated")
    it("call the vaults liquidateVault if the loan can be liquidated")
  })
})
Enter fullscreen mode Exit fullscreen mode

If we aren't using Smock then this is pretty difficult. I would either need to inject a contract address into the LoanLiquidator and then have that address implement erc20Stablecoin's interface. That's for another blog post.

In this post it's a lot simpler because we will use Smock, but there are limitations. First let's focus on it("should revert if the vault cannot be liquidated")

#...
it("should revert if the vault cannot be liquidated", async () => {
  const VAULT_ADDRESS = "0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72"
  # I am using Typechain to generate types for the erc20Stablecoin ABI
  const fake = await smock.fake<Erc20QiStablecoin>(
    Erc20QiStablecoin.abi,
    {address: VAULT_ADDRESS}
  );

  fake.checkLiquidation.returns(false);

  const LoanLiquidatorFactory = await ethers.getContractFactory("LoanLiquidatator") as LoanLiquidator__factory;
  const loanLiquidator = await LoanLiquidatatorFactory.deploy();
  await loanLiquidator.deployed();

  await expect(loanLiquidator.liquidate(1)).to
    .be
    .revertedWith("Vault not below liquidation threshold")

  expect(fake.liquidateVault).not.to.have.been.called
})
#...
Enter fullscreen mode Exit fullscreen mode

The magic here lies in the opts for Smock's #fake() method. You can pass an existing contract address to #fake() and Smock will use Hardhat's [hardhat_setCode](https://hardhat.org/hardhat-network/reference/#hardhat-setcode) rpc call to replace the contract at the address given with Smock's fake implementation of the contract.

Next lets test it("call the vaults liquidateVault if the loan can be liquidated").

it("call the vaults liquidateVault if the loan can be liquidated", async () => {
  const VAULT_ADDRESS = "0x61167073E31b1DAd85a3E531211c7B8F1E5cAE72"
  # I am using Typechain to generate types for the erc20Stablecoin ABI
  const fake = await smock.fake<Erc20QiStablecoin>(
    Erc20QiStablecoin.abi,
    {address: VAULT_ADDRESS}
  );

  fake.checkLiquidation.returns(true);

  const LoanLiquidatorFactory = await ethers.getContractFactory("LoanLiquidatator") as LoanLiquidator__factory;
  const loanLiquidator = await LoanLiquidatorFactory.deploy();
  await loanLiquidator.deployed();

  await expect(loanLiquidator.liquidate(1)).not.to
    .be
    .reverted

  expect(fake.liquidateVault).to.have.been.called
})
Enter fullscreen mode Exit fullscreen mode

In this case you'll get green lights and you can keep on coding. In the real world there's a gotcha. When you fake a contract you fake all of it. By default, functions will now return their Zero value. If you have calls to later in your implementation that require non-zero values.

A clear example of this is if we add the method #getVaultAddress() to our LoanLiquidator contract:

function getVaultAddress() public view returns (address) {
  return vaultAddress;
}
Enter fullscreen mode Exit fullscreen mode

Now in test, after faking, if you call #getVaultAddress() you will get the zero address 0x0000000000000000000000000000000000000000 If you had code that used the returned address you may see an error like:

Error: Transaction reverted: function call to a non-contract account

This just scratches the surface of what's possible with Smock and Solidity. The Web3 space is one of the most test driven development friendly and open ecosystems I have ever encountered.

If you're interested in TDD, writing great software, and developing cutting edge technology, don't hesitate to check out our careers page. Or if you’re looking for a partner to help build your next dApp, backend, or frontend and up-skill your team please reach out to us at work@withfocus.com.

Discussion (2)

Collapse
royalaid profile image
Mark Aiken • Edited on

I am curious how to mock a value like ftmscan.com/address/0x230917f8a262..., line 735, which holds a Mapping of a Mapping to a struct:
mapping (uint256 => mapping (address => UserInfo)) public userInfo;

Collapse
austinbv profile image
Austin Vance Author

Thanks and great question!

Mocking return values is the same, if it's a complex object like arrays or structs or a primitive type. The short answer is you can mock it like this

For userInfo it's a nested map. Nested maps in solidity create a getter functions that have N parameters where N is the number of maps nested. The final return from your getter is

const fake = await smock.fake<Farm>(
    Farm.abi,
    {address: "0x230917f8a262bF9f2C3959eC495b11D1B7E1aFfC"}
  );
fake.userInfo.returns({ amount: 20, rewardDebt: 1 })
Enter fullscreen mode Exit fullscreen mode

The longer answer is Solidity creates getter functions for maps. Each of these functions has N parameters, where N is the number of maps in storage. If you take a look at the ABI for the Farm contract userInfo has two params.

  {
    "constant": true,
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "userInfo",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "rewardDebt",
        "type": "uint256"
      }
    ],
    "payable": false,
    "stateMutability": "view",
    "type": "function"
  }
Enter fullscreen mode Exit fullscreen mode

This represents a solidity interface

  function userInfo ( uint256, address ) external view returns ( uint256 amount, uint256 rewardDebt );
Enter fullscreen mode Exit fullscreen mode

You can mock the userInfo function to return based on specific parameters or have a single general return value.

Deeper info can be found in their docs smock.readthedocs.io/en/latest/fak...