Learn how to create your own ERC20 token payment splitter in one article
Payments are a recurring topic in nearly every area of cryptocurrency, especially offering payments to multiple stakers. For example, a DAO wants to fund multiple initiatives, a DEX wants to merge to distribute transaction fees to certain participants, or a team wants to distribute tokens to team members as a monthly salary.
Smart contracts allow us to automate these types of payment functions, which limits potential errors caused by manually managing payments and allows us to spend valuable time on other productive tasks.
first level title
Prerequisites and Setup
secondary title
project structure
We will create two contracts. The first will be the ERC20 token payment split smart contract and the second will be the mock pool smart contract. The ERC20 token payment splitter smart contract will be abstract and hold the logic and data for managing the sender and payer and their respective payment parts. The mock pool will inherit the ERC20 token payment splitter so that we can automatically distribute payments to multiple stakers. There are two reasons for splitting the payment functionality in two contracts:
Demonstrate the use of token payment split contracts in real-world use cases
Make sure the token payment split contract is flexible enough for anyone to choose and integrate into their own projects
secondary title
Set up the development environment
Tools in this tutorial:
Hardhat - smart contract development environment
OpenZeppelin - audited smart contract template
Now start an NPM project with NPM init -y in an empty directory
After setting up the project, install Hardhat with the following command:
After installing Hardhat, type npx Hardhat and select the option to create a basic example project. This will include a convenient file structure to easily create, test and deploy your own contracts.
Choose to create a basic example project
You can delete the Greeter.sol file in the contract folder and delete the sample-test.js file from the test folder.
We will also install the hardhat plugin library, which are Hardhat plugins. They allow us to add tools for testing and deploying smart contracts.
At the top of the hardhat.config.js file, add
A package called chai needs to be installed to test our smart contracts.
secondary title
Create token payment splitter
This token payment split smart contract will provide the logic to setup and store data related to the list of payees and each payee's share. Each payee holds a number of shares equal to the percentage of funds they should receive (e.g. if there are 4 payees each holding 5 shares, they will each receive 25% of any payout).
To start this contract, we will create a new file in our contracts folder and name it TokenPaymentSplitter.sol.
Set pragma line and contract shell.
pragma solidity ^0.8.0;
abstract contract TokenPaymentSplitter {
}
Note that this is an abstract contract, we will import it into the simulated pool contract later. Making it abstract also allows us to easily import this contract into any other real project in the future.
Now let's import a useful tool from OpenZeppelin.
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
abstract contract TokenPaymentSplitter {
using SafeERC20 for IERC20;
}
SafeERC20.sol provides an ERC20 interface that allows us to call standard functions from any ERC20 smart contract and wrap these calls in additional functions to provide a more secure way of transferring tokens.
Now, we will create variables to store contract data.
abstract contract TokenPaymentSplitter {
using SafeERC20 for IERC20;
address internal paymentToken;
uint256 internal _totalShares;
uint256 internal _totalTokenReleased;
address[] internal _payees;
mapping(address => uint256) internal _shares;
mapping(address => uint256) internal _tokenReleased;
}
paymentToken is the address of the ERC20 token we use for payment.
_totalShares provides summing of shares from all payees.
_totalTokenReleased is the total payment tokens that have been paid out to all payees.
_payees provides an array of all current payee addresses.
_shares is a mapping of recipient addresses to the number of shares assigned to them.
_tokenReleased is a mapping of the recipient address to the payment token amount.
Now place a constructor that accepts three parameters. The first parameter is an array of payees that we wish to initialize on contract deployment. The second parameter is an array of shares for each payee. The third is the address of the ERC20 token that will be used for payment.
pragma solidity 0.8.0
constructor(
address[] memory payees,
uint256[] memory shares_,
address _paymentToken
) {
require(
payees.length == shares_.length,
"TokenPaymentSplitter: payees and shares length mismatch"
);
require(payees.length > 0, "TokenPaymentSplitter: no payees");
for (uint256 i = 0; i < payees.length; i++) {
_addPayee(payees[i], shares_[i]);
}
paymentToken = _paymentToken;
}
The constructor contains a require statement to ensure that both arrays have the same length so that each payee has their share assigned to them. There is another require statement to ensure that the contract is initialized with at least one payee.
There is also a for loop which assigns each payee and their share to the variables we created above. This is done with a function called _addPayee which we will create shortly.
Once the constructor is in place, add a few more functions to call and get contract variables.
pragma solidity 0.8.0
function totalShares() public view returns (uint256) {
return _totalShares;
}
function shares(address account) public view returns (uint256) {
return _shares[account];
}
function payee(uint256 index) public view returns (address) {
return _payees[index];
}
Now we will create the function for adding a payee.
pragma solidity 0.8.0;
function _addPayee(address account, uint256 shares_) internal {
require(
account != address(0),
"TokenPaymentSplitter: account is the zero address"
);
require(shares_ > 0, "TokenPaymentSplitter: shares are 0");
require(
_shares[account] == 0,
"TokenPaymentSplitter: account already has shares"
);
_payees.push(account);
_shares[account] = shares_;
_totalShares = _totalShares + shares_;
}
_addPayee is the function we call in the constructor to set up the payee array. This function takes two parameters, the payee's account and the number of shares associated with it. It then checks to see if the account has a zero address, if the share is greater than zero, and if the account is already registered as a payee. If all checks pass, then we add the data to the respective variables.
Now let's add a function to support distributing tokens to payees.
pragma solidity 0.8.0;
function release(address account) public virtual {
require(
_shares[account] > 0, "TokenPaymentSplitter: account has no shares"
);
uint256 tokenTotalReceived = IERC20(paymentToken).balanceOf(address(this)) + _totalTokenReleased;
uint256 payment = (tokenTotalReceived * _shares[account]) / _totalShares - _tokenReleased[account];
require(payment != 0, "TokenPaymentSplitter: account is not due payment");
_tokenReleased[account] = _tokenReleased[account] + payment;
_totalTokenReleased = _totalTokenReleased + payment;
IERC20(paymentToken).safeTransfer(account, payment);
}
Release is a function that anyone can call that takes an argument of an existing payee account. Let's analyze what happens in this function. First, it checks if the account has shares assigned to it. It then creates a variable called tokenTotalReceived that adds the contract's current token balance to the total number of tokens previously released. Create another variable called payment that determines how much of the total amount of tokens received is owed to the account, and then subtract how much has been released to the account. Then, a require statement checks to see if the current payment amount is greater than zero (i.e., more tokens are currently owed). If the check passes, update the account's tokenReleased and update totalTokenReleased. Finally, the token amount paid to the account is transferred.
Now the function is in place! But this contract has one more thing to do...events!
We will add two events to the contract, it is good practice to add events at the top of the contract.
pragma solidity 0.8.0;
event PayeeAdded(address account, uint256 shares);
event PaymentReleased(address to, uint256 amount);
Once these events are included in the contract, we emit them in the appropriate functions.
pragma solidity 0.8.0;
function _addPayee(address account, uint256 shares_) internal {
///existingFunctionCode
emit PayeeAdded(account, shares_);
}
function release(address account) public virtual {
///existingFunctionCode
emit PaymentReleased(account, payment);
}
secondary title
Create a simulated pool contract
This contract won't be very complicated as we just want to demonstrate how to integrate a token payment splitter. This contract periodically receives specific ERC20 tokens that we want to distribute to a list of payees. This ERC20 token can arrive through different scenarios, like user deposits or redirected fees from another smart contract. In real life, depending on the project, there may be a more complex contract containing more functions to meet the user's use case.
In the contract folder, create a new file called MockPool.sol. Then add the following code.
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "./TokenPaymentSplitter.sol";
contract MockPool is Ownable, TokenPaymentSplitter {
using SafeERC20 for IERC20;
constructor(
address[] memory _payees,
uint256[] memory _shares,
address _paymentToken
) TokenPaymentSplitter(_payees, _shares, _paymentToken) {}
function drainTo(address _transferTo, address _token) public onlyOwner {
require(
_token != paymentToken,
"MockPool: Token to drain is PaymentToken"
);
uint256 balance = IERC20(_token).balanceOf(address(this));
require(balance > 0, "MockPool: Token to drain balance is 0");
IERC20(_token).safeTransfer(_transferTo, balance);
}
}
In this contract, import three things. The first is OpenZeppelin's Ownable utility, which uses a unique onlyOwner modifier on some functions. The second is SafeERC20, which allows safe ERC20 token transfers, as will be seen in the contract. The third is our TokenPaymentSplitter contract.
In the MockPool constructor, we need the same three parameters from TokenPaymentSplitter, we just pass them to our inherited contract.
secondary title
test contract
Testing smart contracts is just as important as creating them. The assets these contracts deal with usually belong to someone else, so it's our responsibility as developers to make sure that those assets work the way they're supposed to, and that our tests cover almost every edge case.
The tests that will be performed here are some examples to show that the TokenPaymentSplitter smart contract works as we expect. When working on your own projects, you may wish to create tests that are tailored specifically to your own use case.
To support our tests we want to include an ERC20 token, for this we will create a new solididity file that imports the OpenZepplin ERC20 template for use in our tests. In the contracts folder, create a new file called Imports.sol and include the following code:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";
contract Imports {}
Now, create a file called test.js in the test folder. At the top of this file, we'll import the packages that support our tests.
const { expect } = require('chai')
const { ethers } = require('hardhat')
Now, to set up the tests, we will first create the necessary variables, create the beforeEach function, which is called before each test, and create an empty describe function, which will shortly contain our tests.
describe('TokenPaymentSplitter Tests', () => {
let deployer
let account1
let account2
let account3
let account4
let testPaymentToken
let mockPool
beforeEach(async () => {
[deployer, account1, account2, account3, account4] = await ethers.getSigners()
const TestPaymentToken = await ethers.getContractFactory('ERC20PresetMinterPauser')
testPaymentToken = await TestPaymentToken.deploy('TestPaymentToken', 'TPT')
await testPaymentToken.deployed()
})
describe('Add payees with varying amounts and distribute payments', async () => {}
}
first level title
Payment tokens are equally distributed among multiple payees
In our first test, we wanted to see what happens when we deploy a contract that contains a list of payees with an evenly distributed share. Below is the test code.
it('payment token is distributed evenly to multiple payees', async () => {
payeeAddressArray = [account1.address, account2.address, account3.address, account4.address]
payeeShareArray = [10, 10, 10, 10]
const MockPool = await ethers.getContractFactory('MockPool')
mockPool = await MockPool.deploy(
payeeAddressArray,
payeeShareArray,
testPaymentToken.address
)
await mockPool.deployed()
await testPaymentToken.mint(mockPool.address, 100000)
await mockPool
.connect(account1)
.release(account1.address)
await mockPool
.connect(account2)
.release(account2.address)
await mockPool
.connect(account3)
.release(account3.address)
await mockPool
.connect(account4)
.release(account4.address)
const account1TokenBalance = await testPaymentToken.balanceOf(account1.address)
const account2TokenBalance = await testPaymentToken.balanceOf(account2.address)
const account3TokenBalance = await testPaymentToken.balanceOf(account3.address)
const account4TokenBalance = await testPaymentToken.balanceOf(account4.address)
expect(account1TokenBalance).to.equal(25000)
expect(account2TokenBalance).to.equal(25000)
expect(account3TokenBalance).to.equal(25000)
expect(account4TokenBalance).to.equal(25000)
})
first level title
Payment tokens are distributed unevenly among multiple payees
In the second test, we want to make sure that the math works even if each recipient's share is unevenly distributed.
it('payment token is distributed unevenly to multiple payees', async () => {
payeeAddressArray = [account1.address, account2.address, account3.address, account4.address]
payeeShareArray = [10, 5, 11, 7]
const MockPool = await ethers.getContractFactory('MockPool')
mockPool = await MockPool.deploy(
payeeAddressArray,
payeeShareArray,
testPaymentToken.address
)
await mockPool.deployed()
await testPaymentToken.mint(mockPool.address, 100000)
await mockPool
.connect(account1)
.release(account1.address)
await mockPool
.connect(account2)
.release(account2.address)
await mockPool
.connect(account3)
.release(account3.address)
await mockPool
.connect(account4)
.release(account4.address)
const mockPoolTestPaymentTokenBalance = await testPaymentToken.balanceOf(
mockPool.address
)
const account1TokenBalance = await testPaymentToken.balanceOf(account1.address)
const account2TokenBalance = await testPaymentToken.balanceOf(account2.address)
const account3TokenBalance = await testPaymentToken.balanceOf(account3.address)
const account4TokenBalance = await testPaymentToken.balanceOf(account4.address)
expect(mockPoolTestPaymentTokenBalance).to.equal(1)
expect(account1TokenBalance).to.equal(30303)
expect(account2TokenBalance).to.equal(15151)
expect(account3TokenBalance).to.equal(33333)
expect(account4TokenBalance).to.equal(21212)
})
first level title
Payment tokens are unevenly distributed to multiple payees and additional payment tokens are sent to the pool
This is similar to the previous test, but with the addition of more payment tokens being sent to the pool before the funds are released to the recipient. This shows that as payment tokens continue to flow into the simulated pool contract, the math can still ensure that the payee receives the correct amount.
it('payment token is distributed unevenly to multiple payees with additional payment token sent to pool', async () => {
payeeAddressArray = [account1.address, account2.address, account3.address, account4.address]
payeeShareArray = [10, 5, 11, 7]
const MockPool = await ethers.getContractFactory('MockPool')
mockPool = await MockPool.deploy(
payeeAddressArray,
payeeShareArray,
testPaymentToken.address
)
await mockPool.deployed()
await testPaymentToken.mint(mockPool.address, 100000)
await mockPool
.connect(account1)
.release(account1.address)
await mockPool
.connect(account2)
.release(account2.address)
await testPaymentToken.mint(mockPool.address, 100000)
await mockPool
.connect(account3)
.release(account3.address)
await mockPool
.connect(account4)
.release(account4.address)
await mockPool
.connect(account1)
.release(account1.address)
await mockPool
.connect(account2)
.release(account2.address)
const mockPoolTestPaymentTokenBalance = await testPaymentToken.balanceOf(
mockPool.address
)
const account1TokenBalance = await testPaymentToken.balanceOf(account1.address)
const account2TokenBalance = await testPaymentToken.balanceOf(account2.address)
const account3TokenBalance = await testPaymentToken.balanceOf(account3.address)
const account4TokenBalance = await testPaymentToken.balanceOf(account4.address)
expect(mockPoolTestPaymentTokenBalance).to.equal(1)
expect(account1TokenBalance).to.equal(60606)
expect(account2TokenBalance).to.equal(30303)
expect(account3TokenBalance).to.equal(66666)
expect(account4TokenBalance).to.equal(42424)
})
Now that all the tests are in place, it's time to run them and see if they work! In the project root folder, start the tests with npx hardhat test . If everything is correct, then you should see all the green squares as shown in the image below.
Summarize
Summarize
This article comes from the decentralized financial community and is reproduced with authorization.
Source:https://medium.com/coinmonks/create-an-erc20-token-payment-splitting-smart-contract-c79436470ccc
This article comes from the decentralized financial community and is reproduced with authorization.


