Company - Nov 29, 2022

Escrow: Part 1 - Smart Contract

By: Carl Barrdahl

Are you loving Carl's work? Check out his Freeflow profile to view his skills and connect with him for your next web3 project!

Web3 and Smart Contracts have opened up the possibilities of collaboration without relying as much on third parties. Examples of these are escrows where funds are deposited by a payer (client) and later released to a payee (talent).

The conditions for this release of funds can be described in different ways. Ideally, it should be easy for the client to release funds to the talent and at the same time allow the talent to request funds to be released and resolved by a mediator if necessary. The client might also want to withdraw their deposited funds and this action should be approved by another party.

I recently built such an escrow for Freeflow and here is a write-up describing the process.

You can find the code here: https://github.com/carlbarrdahl/escrow

Background

This Escrow service is built for when a client wants to hire a developer to perform some work. Prior to starting the work, the developer wants to know that funds for this work have been allocated. The developer wants the funds to be released when the work is finished. This will be done once the client approves the work.

A third-party mediator or resolver acts as an arbiter if the client and provider cannot agree. The resolver receives a percentage fee from the released funds. This arbitration is done by voting. The funds are released if 2/3 parties agree on the token and amount. The client can release deposited funds to the developer directly without requiring a second vote.

Smart Contracts

We will write two smart contracts:

Setting up the project

npx hardhat # Setup hardhat project▸ Create a TypeScript project # Select Typescriptpnpm add @openzeppelin/contracts # Install OpenZeppelin contracts (or use yarn)rm -rf {contracts,test}/* # Remove existing contract and testtouch contracts/Escrow.sol contracts/EscrowFactory.sol test/Escrow.ts

Escrow

First, let’s create the structure of the contract and import the necessary OpenZeppelin contracts.

Initializable lets us instantiate the contract from the EscrowFactory later on. It gives us a way to deploy the contract from the front end in a gas-efficient way. More on this in the EscrowFactory section.

// SPDX-License-Identifier: MITpragma solidity ^0.8.16;import "@openzeppelin/contracts/token/ERC20/IERC20.sol";import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";import "@openzeppelin/contracts/proxy/utils/Initializable.sol";contract Escrow is Initializable {    using SafeERC20 for IERC20;     bool private initialized;    address public client;    address public talent;    address public resolver;    uint256 public fee = 1500;    // Used to manage voting state    uint8 private constant RELEASE = 1;    uint8 private constant REFUND = 2;    mapping(uint8 => mapping(bytes => uint8)) public votes;    mapping(uint8 => mapping(bytes => mapping(address => bool))) public isConfirmed;    event Confirmed(address from, address token, uint256 amount, uint8 vote);    event Released( address from, address to, address token, uint256 amount, string note);    event Refunded( address from, address to, address token, uint256 amount, string note);    event Deposit( address from, address to, address token, uint256 amount, string note);}

Next, we want to have a way to set the addresses for clienttalentresolver, and the fee. This is where the init function comes in. It’s similar to a constructor but for Initializable contracts.

function init( address _client, address _provider, address _resolver, uint256 _fee) external payable initializer {    require(_client != address(0), "Client address required");    require(_provider != address(0), "Provider address required");    require(_resolver != address(0), "Resolver address required");    require(_fee < 10000, "Fee must be a value of 0 - 99999");    client = _client;    talent = _provider;    resolver = _resolver;    fee = _fee;    initialized = true;}

The Escrow contract contains three functions: depositrelease, and refund. Each of these functions has two variants, one for ETH and one for ERC20 tokens. For brevity, we will focus on the ERC20 variants here.

Deposit

The deposit function receives a few parameters:

// hardhat/contracts/Escrow.solfunction deposit(    address _token,    uint256 _amount,    uint256 _releaseAmount,    string memory _note) external onlyInitialized {    	// Transfer tokens from sender to contract    IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);    	// Emit an event we can query and display on the frontend    emit Deposit(msg.sender, talent, _token, _amount, _note);    if (_releaseAmount > 0) {        _release(_token, _releaseAmount, _note);    }}

Before writing a test for our contract we must first create a test token to deposit into the contract. Create a new file in hardhat/contracts/test/TestToken.sol

// SPDX-License-Identifier: MITpragma solidity ^0.8.16;import "@openzeppelin/contracts/token/ERC20/ERC20.sol";contract TestToken is ERC20 {    constructor() ERC20("TestToken", "TOK") {}    function mint(address to, uint256 amount) public {        _mint(to, amount);    }}

Testing the contract. You can find all the tests in the GitHub repo.

// hardhat/test/Escrow.tsimport { expect } from "chai";import { ethers } from "hardhat";const AMOUNT = ethers.utils.parseEther("1");// Helper function to deploy the contracts and return themasync function deploy() {  const Escrow = await ethers.getContractFactory("Escrow");  const Token = await ethers.getContractFactory("TestToken");  const EscrowFactory = await ethers.getContractFactory("EgescrowFactory");  const token = await Token.deploy();  const escrow = await Escrow.deploy();  const factory = await EscrowFactory.deploy(escrow.address);  return { escrow, token, factory, Escrow };}describe("Escrow", () => {  it("should deposit ERC20 tokens", async () => {    const { escrow, token } = await deploy();    	// These are all the different accounts    const [owner, client, talent, resolver] = await ethers.getSigners();    // Mint test token for us to test with    await token.mint(client.address, AMOUNT);    // Approve escrow to transfer our tokens    await token.connect(client).approve(escrow.address, AMOUNT);    await escrow.init(client.address, talent.address, resolver.address, 1500);    // A successful deposit will emit the Deposit event with the correct parameters    await expect(escrow.connect(client).deposit(token.address, AMOUNT, 0, "note"))      .to.emit(escrow, "Deposit")      .withArgs(        client.address,        talent.address,        token.address,        AMOUNT,        "note"      );    // Check the balance of the token in the escrow    	expect(await token.balanceOf(escrow.address)).to.eq(AMOUNT);  });  it("should deposit ERC20 tokens and release immediately", async () => {    	...    });});

Release

The release function, similar to deposit, also accepts a token address, amount, and note.

First, we check if the sender is the client. If this is the case, release the funds immediately. We calculate the amount to send to the resolver and transfer these amounts from the contract to the developer and the resolver.

function release(    address _token,    uint256 _amount,    string memory _note) external onlyParty onlyInitialized {    bool isClient = msg.sender == client;    if (isClient || _countVotes(RELEASE, _token, _amount)) {        _release(_token, _amount, _note);    }}function _release(    address _token,    uint256 _amount,    string memory _note) internal {    _resetVotes(RELEASE, _token, _amount);    uint256 resolverShare = _calcShare(_amount);    IERC20(_token).safeTransfer(resolver, resolverShare);    IERC20(_token).safeTransfer(talent, _amount - resolverShare);    emit Released(msg.sender, talent, _token, _amount, _note);}function _calcShare(uint256 _amount) internal view returns (uint256) {    return (_amount / 10_000) * fee;}

However, if the wallet calling this function is the developer or the resolver, we only want to release the funds if 2 out of 3 parties agree on the same token and amount. This is done in the _countVotes function.

By hashing the token address and the amount we get a unique hash for this specific combination. We can use that as an id to track what addresses have confirmed what tokens to release. The type here is simply an identifier of either REFUND or RELEASE as (0 or 1).

We will use two mappings, one to keep track of whether the sender has confirmed or not, and one to count the votes.

Finally, we check if there are two votes and return a boolean.

function _countVotes(    uint8 _type,    address _token,    uint256 _amount) internal returns (bool) {    bytes memory hash = abi.encodePacked(_token, _amount);    require(!isConfirmed[_type][hash][msg.sender], "Already confirmed");    isConfirmed[_type][hash][msg.sender] = true;    votes[_type][hash] += 1;    emit Confirmed(msg.sender, _token, _amount, _type);    return votes[_type][hash] == 2;}function _resetVotes(    uint8 _type,    address _token,    uint256 _amount) internal {    bytes memory hash = abi.encodePacked(_token, _amount);    votes[_type][hash] = 0;    isConfirmed[_type][hash][client] = false;    isConfirmed[_type][hash][talent] = false;    isConfirmed[_type][hash][resolver] = false;}

There is also an onlyParty modifier that is being run before the release and refund functions. This makes sure only the correct addresses can call these functions.

modifier onlyParty() {    require(    	        msg.sender == client ||            msg.sender == talent ||            msg.sender == resolver,        "Sender not part of party"    );    _;}
// hardhat/test/Escrow.tsconst RESOLVER_SHARE = AMOUNT.div(10_000).mul(1500);const PROVIDER_SHARE = AMOUNT.sub(RESOLVER_SHARE);describe("Release", () => {    it("must be sent by a party address", async () => {      const { escrow, token } = await deploy();      const [owner, client, talent, resolver] = await ethers.getSigners();      await escrow.init(client.address, talent.address, resolver.address, 1500);      await expect(        escrow.release(token.address, AMOUNT, "note")      ).to.revertedWith("Sender not part of party");    });    it("requires two votes to release funds", async () => {      const { escrow, token } = await deploy();      const [owner, client, talent, resolver] = await ethers.getSigners();      await escrow.init(client.address, talent.address, resolver.address, 1500);      await token.mint(owner.address, AMOUNT);      await token.transfer(escrow.address, AMOUNT);      await expect(escrow.connect(talent).release(token.address, AMOUNT, "note"))        .to.emit(escrow, "Confirmed")        .withArgs(talent.address, token.address, AMOUNT, 1);      await expect(escrow.connect(resolver).release(token.address, AMOUNT, "note"))        .to.emit(escrow, "Released")        .withArgs(          resolver.address,          talent.address,          token.address,          AMOUNT,          "note"        );      expect(await token.balanceOf(resolver.address)).to.eq(RESOLVER_SHARE);      expect(await token.balanceOf(talent.address)).to.eq(PROVIDER_SHARE);    });});

Refund

Refund also uses the same voting mechanism and transfers the funds back to the client if 2 parties agree on the same token and amount.

function refund(    address _token,    uint256 _amount,    string memory _note) external onlyParty onlyInitialized {    if (_countVotes(REFUND, _token, _amount)) {        IERC20(_token).safeTransfer(client, _amount);        _resetVotes(REFUND, _token, _amount);        emit Refunded(msg.sender, client, _token, _amount, _note);    }}

EscrowFactory

The EscrowFactory is just a simple contract that deploys an Escrow contract and emits an event. By using Clones we can clone a deployed contract and instantiate it with init. The reason we do this is that it’s more modular and costs less gas to deploy this way compared to new Escrow().

With new Escrow() we would need to import the Escrow contract thus adding to the contract file size. Here, we can keep the contracts separate and simply pass the implementation contract as a parameter in our constructor.

We do however need an interface to be able to call init so we just define it at the end of the contract.

// hardhat/contracts/EscrowFactory.sol// SPDX-License-Identifier: MITpragma solidity ^0.8.16;import "@openzeppelin/contracts/proxy/Clones.sol";contract EscrowFactory {    address public immutable implementation;    event EscrowCreated(        address escrow,        address client,        address talent,        address resolver,    			uint256 fee    );    constructor(address _implementation) {        implementation = _implementation;    }    function create(        address _client,        address _talent,        address _resolver,        uint256 _fee    ) external payable returns (address) {        address escrow = Clones.clone(implementation);        IEscrow(escrow).init(_client, _talent, _resolver, _fee);        emit EscrowCreated(escrow, _client, _talent, _resolver, _fee);        return escrow;    }}interface IEscrow {    function init( address _client, address _talent, address _resolver, uint256 _fee) external;}

Deploying the contracts

Before we can interact with these contracts from a front end, we need to deploy them. The simplest way is to update the deploy scripts:

// hardhat/scripts/deploy.tsimport { ethers, hardhatArguments } from "hardhat";async function main() {  const Escrow = await ethers.getContractFactory("Escrow");  const EscrowFactory = await ethers.getContractFactory("EscrowFactory");  const escrow = await Escrow.deploy();  const factory = await EscrowFactory.deploy(escrow.address);  await escrow.deployed();  await factory.deployed();  console.log(`Contracts deployed:`);  console.log(`Factory: ${factory.address}`);  console.log(` Escrow: ${escrow.address}`);  if (hardhatArguments.network === "localhost") {    const Token = await ethers.getContractFactory("TestToken");    const token = await Token.deploy();    await token.deployed();    console.log(`  Token: ${token.address}`);  }}main().catch((error) => {  console.error(error);  process.exitCode = 1;});
npx hardhat node # Start the local Hardhat node# Open another terminal and runnpx hardhat --network localhost run scripts/deploy.ts# Will successfully output:Contracts deployed:Factory: 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 Escrow: 0x0165878A594ca255338adfa4d48449f69242Eb8F  Token: 0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6

To deploy to other networks we need to update the hardhat.config.ts file with a config for the networks we want to deploy to.

We can also get a printout for an estimate of how much gas the contracts cost, both deployment and calls.

import { HardhatUserConfig, task } from "hardhat/config";import "@nomicfoundation/hardhat-toolbox";import fs from "fs";import path from "path";import * as dotenv from "dotenv";dotenv.config({ path: ".env" });// Make sure there is a .env file with these variables definedconst ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY;const COINMARKETCAP_KEY = process.env.COINMARKETCAP_KEY;const ETHERSCAN_KEY = process.env.ETHERSCAN_KEY;const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY;const config: HardhatUserConfig = {  solidity: {    version: "0.8.17",    settings: { optimizer: { enabled: true, runs: 1000 } },  },  networks: {    goerli: {      url: `https://eth-goerli.alchemyapi.io/v2/${ALCHEMY_API_KEY}`,      accounts: [WALLET_PRIVATE_KEY as string],    },    mainnet: {}  },  gasReporter: {    enabled: true,    currency: "USD",    coinmarketcap: COINMARKETCAP_KEY,    token: "ETH",    gasPriceApi:      "https://api.etherscan.io/api?module=proxy&action=eth_gasPrice",  },  etherscan: { apiKey: ETHERSCAN_KEY },};export default config;

Next steps

Now that we’ve deployed our contracts, the next steps are to build a front end where we can interact with them. In the next article we will cover:

Are you loving Carl's work? Check out his socials to view his skills and connect with him for your next web3 project!


Copyright Freeflow © 2023. All rights reserved.