How to Escrow ERC20 tokens in Solidity with Remix

Tim Hysniu
6 min readJul 29, 2021

--

Photo by Executium on Unsplash

Lets say we want to create an escrow system that makes use of ERC20 tokens instead of Ethereum. For example, lets say I am a peer to peer crypto marketplace and I want to allow sellers to transact with buyers safely. To do this, I want to make sure that seller honors the transaction after the buyer has sent a payment. Similarly, if buyer does not send a payment then his money is returned.

Requirements

1. Remix IDE

It’s always preferable to use truffle for testing since it helps to automate testing. Deploying and running transactions with specific accounts can be pretty tedious and error prone in Remix but if the contracts are not that complex it's probably okay.

This contract is simple enough and we want to test it using a UI so using Ethereum’s remix will work just fine.

2. Your ERC20 Token

If you haven’t created the ERC20 token yet this can be a relatively quick process. We can extend Open Zeppelin’s ERC20 contract and deploy it with remix:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.6;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol";contract P2PM is ERC20 {
address public admin;
constructor() ERC20("dust", "P2PM") {
_mint(msg.sender, 20000000 * 10 ** decimals());
admin = msg.sender;
}
}

Escrow Contract for ERC20 Tokens

When a seller agrees to trading with a buyer, their funds are moved to the escrow contract. Escrow contract is now the owner of those funds. Seller can withdraw the funds from the contract with withdraw function, but this can only happen after the escrow is unlocked.

We’ve assigned the address that deployed the contract as an admin. According to the contract, only seller can withdraw tokens back to their wallet.

admin can only do two things:

  • unlock a transaction. This means transaction between buyer and seller went smoothly and seller can now withdraw the amount.
  • move transaction tokens to buyer. In case of disputes an arbitrator needs to decide in favor of seller or buyer. If buyer wins then tokens are moved to his account.

Escrow Contract for ERC20 Tokens

When a seller agrees to trading with a buyer, their funds are moved to the escrow contract. Escrow contract is now the owner of those funds. Seller can withdraw the funds from the contract with withdraw function, but this can only happen after the escrow is unlocked.

We’ve assigned the address that deployed the contract as an admin. According to the contract, only seller can withdraw tokens back to their wallet.

admin can only do two things:

  • unlock a transaction. This means transaction between buyer and seller went smoothly and seller can now withdraw the amount.
  • move transaction tokens to buyer. In case of disputes an arbitrator needs to decide in favor of seller or buyer. If buyer wins then tokens are moved to his account.

The full contract making use of P2PM erc20 token, actually used by P2Pmoon, is below:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.6;

import "./p2pm.sol";

contract Escrow {
address admin;
uint256 public totalBalance;

// this is the P2PM erc20 contract address
address constant p2pmAddress = 0xd9145CCE52D386f254917e481eB44e9943F39138;

struct Transaction {
address buyer;
uint256 amount;
bool locked;
bool spent;
}

mapping(address => mapping(address => Transaction)) public balances;

modifier onlyAdmin {
require(msg.sender == admin, "Only admin can unlock escrow.");
_;
}

constructor() {
admin = msg.sender;
}

// seller accepts a trade, erc20 tokens
// get moved to the escrow (this contract)
function accept(address _tx_id, address _buyer, uint256 _amount) external returns (uint256) {
P2PM token = P2PM(p2pmAddress);
token.transferFrom(msg.sender, address(this), _amount);
totalBalance += _amount;
balances[msg.sender][_tx_id].amount = _amount;
balances[msg.sender][_tx_id].buyer = _buyer;
balances[msg.sender][_tx_id].locked = true;
balances[msg.sender][_tx_id].spent = false;
return token.balanceOf(msg.sender);
}

// retrieve current state of transaction in escrow
function transaction(address _seller, address _tx_id) external view returns (uint256, bool, address) {
return ( balances[_seller][_tx_id].amount, balances[_seller][_tx_id].locked, balances[_seller][_tx_id].buyer );
}

// admin unlocks tokens in escrow for a transaction
function release(address _tx_id, address _seller) onlyAdmin external returns(bool) {
balances[_seller][_tx_id].locked = false;
return true;
}

// seller is able to withdraw unlocked tokens
function withdraw(address _tx_id) external returns(bool) {
require(balances[msg.sender][_tx_id].locked == false, 'This escrow is still locked');
require(balances[msg.sender][_tx_id].spent == false, 'Already withdrawn');

P2PM token = P2PM(p2pmAddress);
token.transfer(msg.sender, balances[msg.sender][_tx_id].amount);

totalBalance -= balances[msg.sender][_tx_id].amount;
balances[msg.sender][_tx_id].spent = true;
return true;
}

// admin can send funds to buyer if dispute resolution is in buyer's favor
function resolveToBuyer(address _seller, address _tx_id) onlyAdmin external returns(bool) {
P2PM token = P2PM(p2pmAddress);
token.transfer(balances[_seller][_tx_id].buyer, balances[msg.sender][_tx_id].amount);

balances[_seller][_tx_id].spent = true;
totalBalance -= balances[_seller][_tx_id].amount;
return true;
}


}

Testing Escrow Flow in Remix

We wanted to simulate the flow from seller accepting the trade to escrow and back to sellers wallet. This is a bit of a manual process, but it helps you visually see the escrow contract working with your ERC20 token.

Step 1: Seller approves transfer to escrow

Let’s say the value that should be held in escrow is 70. If you are using 18 decimals thats 70000000000000000000.

First you find the address of the escrow contract that you deployed. Since we’re using remix you can find this under deployed contracts.

Step 2: Seller executes accept() in escrow contract

When seller calls accept function (eg. via web3 in node) then ERC20 tokens will be moved from seller to the escrow contract. This transaction has to be signed by seller and transaction ID is an identifier for this transaction. It will be locked initially and can be unlocked by escrow admin after transaction is successful.

Step 3: Execute transaction() to see transaction details

To see the status of the transaction you can always call:
transaction(_seller, _tx_id)

Step 4: Escrow admin executes release()

If transaction is successfull admin can unlock tokens in this escrow. This has to be signed by the admin and as you probably know by now that is the deployer of the escrow contract.

It’s important to run this as the admin. If anyone other than admin tries to release the funds you will be able to see an error in remix:

transact to Escrow.release errored: VM error: revert.The transaction has been reverted to the initial state.
Reason provided by the contract: "Only admin can unlock escrow.".
Debug the transaction to get more information.

Step 5: Seller calls withdraw()

This is a happy transaction and the seller is able to withdraw their tokens from escrow and back to their own wallet.

In cases where there was a dispute and admin decided in buyer’s favor then resolveToBuyer() function would be executed instead of release() in step 4. The tokens are then moved to buyers address and transaction is marked as spent.

Why Use ERC20 Escrow?

There are many different use cases where escrowing ERC20 tokens makes sense. For example, if the token is a stable coin built on top of Ethereum chain then we might want a way to safely move ERC20 tokens around.

Another use case is that of P2PMoon where sellers place P2PM coins as collateral when doing transactions. As long as the value of P2PM coins is the same as the amount of cryptocurrency being sold, the sellers have the ability to sell any coin they want. If there is a foul play by either party the escrow admins have the authority to resolve the dispute by moving tokens to either buyer or seller. In the case of buyer, yes its inconvenient to end up with a token they didn’t initially request to purchase but this shouldn’t happen often.

Escrow Contract Improvements

This contract appears to be working. To finalize we need to tidy up the syntax. For simplicity we have been using -= and += to perform arithmetic but we want to do this safely. OpenZeppelin has a SafeMath library that can be used for any math operations.

Perhaps we can also clean up the data structures if they are no longer in use. At the moment the blockchain will contain all escrow transactions. This can be a good thing as a historical reference but you may want to have smaller mapping per seller in case we want to iterate over seller transactions.

--

--

Tim Hysniu

Software Engineer, Technology Evangelist, Entrepreneur