Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend PaymentSplitter to support ERC20 tokens #2858

Merged
merged 15 commits into from Oct 7, 2021
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -5,6 +5,7 @@
* `Ownable`: add an internal `_transferOwnership(address)`. ([#2568](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2568))
* `AccessControl`: add internal `_grantRole(bytes32,address)` and `_revokeRole(bytes32,address)`. ([#2568](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2568))
* `AccessControl`: mark `_setupRole(bytes32,address)` as deprecated in favor of `_grantRole(bytes32,address)`. ([#2568](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2568))
* `PaymentSplitter`: now supports ERC20 assets in addition to Ether. ([#2858](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/#2858))

## 4.3.1

Expand Down
65 changes: 61 additions & 4 deletions contracts/finance/PaymentSplitter.sol
Expand Up @@ -2,6 +2,7 @@

pragma solidity ^0.8.0;

import "../token/ERC20/utils/SafeERC20.sol";
import "../utils/Address.sol";
import "../utils/Context.sol";

Expand All @@ -17,10 +18,15 @@ import "../utils/Context.sol";
* `PaymentSplitter` follows a _pull payment_ model. This means that payments are not automatically forwarded to the
* accounts but kept in this contract, and the actual transfer is triggered as a separate step by calling the {release}
* function.
*
* NOTE: This contract assumes that ERC20 tokens will behave similarly to native tokens (Ether). Rebasing tokens, and
* tokens that apply fees during transfers, are likely to not be supported as expected. If in doubt, we encourage you
* to run tests before sending real value to this contract.
*/
contract PaymentSplitter is Context {
event PayeeAdded(address account, uint256 shares);
event PaymentReleased(address to, uint256 amount);
event ERC20PaymentReleased(IERC20 indexed token, address to, uint256 amount);
event PaymentReceived(address from, uint256 amount);

uint256 private _totalShares;
Expand All @@ -30,6 +36,9 @@ contract PaymentSplitter is Context {
mapping(address => uint256) private _released;
address[] private _payees;

mapping(IERC20 => uint256) private _erc20TotalReleased;
mapping(IERC20 => mapping(address => uint256)) private _erc20Released;

/**
* @dev Creates an instance of `PaymentSplitter` where each account in `payees` is assigned the number of shares at
* the matching position in the `shares` array.
Expand Down Expand Up @@ -73,6 +82,14 @@ contract PaymentSplitter is Context {
return _totalReleased;
}

/**
* @dev Getter for the total amount of `token` already released. `token` should be the address of an IERC20
* contract.
*/
function totalReleased(IERC20 token) public view returns (uint256) {
return _erc20TotalReleased[token];
}

/**
* @dev Getter for the amount of shares held by an account.
*/
Expand All @@ -87,6 +104,14 @@ contract PaymentSplitter is Context {
return _released[account];
}

/**
* @dev Getter for the amount of `token` tokens already released to a payee. `token` should be the address of an
* IERC20 contract.
*/
function released(IERC20 token, address account) public view returns (uint256) {
return _erc20Released[token][account];
}

/**
* @dev Getter for the address of the payee number `index`.
*/
Expand All @@ -101,18 +126,50 @@ contract PaymentSplitter is Context {
function release(address payable account) public virtual {
require(_shares[account] > 0, "PaymentSplitter: account has no shares");

uint256 totalReceived = address(this).balance + _totalReleased;
uint256 payment = (totalReceived * _shares[account]) / _totalShares - _released[account];
uint256 totalReceived = address(this).balance + totalReleased();
uint256 payment = _pendingPayment(account, totalReceived, released(account));

require(payment != 0, "PaymentSplitter: account is not due payment");

_released[account] = _released[account] + payment;
_totalReleased = _totalReleased + payment;
_released[account] += payment;
_totalReleased += payment;

Address.sendValue(account, payment);
emit PaymentReleased(account, payment);
}

/**
* @dev Triggers a transfer to `account` of the amount of `token` tokens they are owed, according to their
* percentage of the total shares and their previous withdrawals. `token` must be the address of an IERC20
* contract.
*/
function release(IERC20 token, address account) public virtual {
require(_shares[account] > 0, "PaymentSplitter: account has no shares");

uint256 totalReceived = token.balanceOf(address(this)) + totalReleased(token);
uint256 payment = _pendingPayment(account, totalReceived, released(token, account));

require(payment != 0, "PaymentSplitter: account is not due payment");

_erc20Released[token][account] += payment;
_erc20TotalReleased[token] += payment;

SafeERC20.safeTransfer(token, account, payment);
emit ERC20PaymentReleased(token, account, payment);
}

/**
* @dev internal logic for computing the pending payment of an `account` given the token historical balances and
* already released amounts.
*/
function _pendingPayment(
address account,
uint256 totalReceived,
uint256 alreadyReleased
) private view returns (uint256) {
return (totalReceived * _shares[account]) / _totalShares - alreadyReleased;
}

/**
* @dev Add a new payee to the contract.
* @param account The address of the payee to add.
Expand Down
140 changes: 103 additions & 37 deletions test/finance/PaymentSplitter.test.js
Expand Up @@ -4,6 +4,7 @@ const { ZERO_ADDRESS } = constants;
const { expect } = require('chai');

const PaymentSplitter = artifacts.require('PaymentSplitter');
const Token = artifacts.require('ERC20Mock');

contract('PaymentSplitter', function (accounts) {
const [ owner, payee1, payee2, payee3, nonpayee1, payer1 ] = accounts;
Expand Down Expand Up @@ -50,6 +51,7 @@ contract('PaymentSplitter', function (accounts) {
this.shares = [20, 10, 70];

this.contract = await PaymentSplitter.new(this.payees, this.shares);
this.token = await Token.new('MyToken', 'MT', owner, ether('1000'));
});

it('has total shares', async function () {
Expand All @@ -63,10 +65,18 @@ contract('PaymentSplitter', function (accounts) {
}));
});

it('accepts payments', async function () {
await send.ether(owner, this.contract.address, amount);
describe('accepts payments', async function () {
it('Ether', async function () {
await send.ether(owner, this.contract.address, amount);

expect(await balance.current(this.contract.address)).to.be.bignumber.equal(amount);
expect(await balance.current(this.contract.address)).to.be.bignumber.equal(amount);
});

it('Token', async function () {
await this.token.transfer(this.contract.address, amount, { from: owner });

expect(await this.token.balanceOf(this.contract.address)).to.be.bignumber.equal(amount);
});
});

describe('shares', async function () {
Expand All @@ -80,51 +90,107 @@ contract('PaymentSplitter', function (accounts) {
});

describe('release', async function () {
it('reverts if no funds to claim', async function () {
await expectRevert(this.contract.release(payee1),
'PaymentSplitter: account is not due payment',
);
describe('Ether', async function () {
it('reverts if no funds to claim', async function () {
await expectRevert(this.contract.release(payee1),
'PaymentSplitter: account is not due payment',
);
});
it('reverts if non-payee want to claim', async function () {
await send.ether(payer1, this.contract.address, amount);
await expectRevert(this.contract.release(nonpayee1),
'PaymentSplitter: account has no shares',
);
});
});
it('reverts if non-payee want to claim', async function () {
await send.ether(payer1, this.contract.address, amount);
await expectRevert(this.contract.release(nonpayee1),
'PaymentSplitter: account has no shares',
);

describe('Token', async function () {
it('reverts if no funds to claim', async function () {
await expectRevert(this.contract.release(this.token.address, payee1),
'PaymentSplitter: account is not due payment',
);
});
it('reverts if non-payee want to claim', async function () {
await send.ether(payer1, this.contract.address, amount);
Amxx marked this conversation as resolved.
Show resolved Hide resolved
await expectRevert(this.contract.release(this.token.address, nonpayee1),
'PaymentSplitter: account has no shares',
);
});
});
});

it('distributes funds to payees', async function () {
await send.ether(payer1, this.contract.address, amount);
describe('distributes funds to payees', async function () {
it('Ether', async function () {
await send.ether(payer1, this.contract.address, amount);

// receive funds
const initBalance = await balance.current(this.contract.address);
expect(initBalance).to.be.bignumber.equal(amount);

// distribute to payees

// receive funds
const initBalance = await balance.current(this.contract.address);
expect(initBalance).to.be.bignumber.equal(amount);
const tracker1 = await balance.tracker(payee1);
const { logs: logs1 } = await this.contract.release(payee1);
const profit1 = await tracker1.delta();
expect(profit1).to.be.bignumber.equal(ether('0.20'));
expectEvent.inLogs(logs1, 'PaymentReleased', { to: payee1, amount: profit1 });

// distribute to payees
const tracker2 = await balance.tracker(payee2);
const { logs: logs2 } = await this.contract.release(payee2);
const profit2 = await tracker2.delta();
expect(profit2).to.be.bignumber.equal(ether('0.10'));
expectEvent.inLogs(logs2, 'PaymentReleased', { to: payee2, amount: profit2 });

const tracker1 = await balance.tracker(payee1);
const { logs: logs1 } = await this.contract.release(payee1);
const profit1 = await tracker1.delta();
expect(profit1).to.be.bignumber.equal(ether('0.20'));
expectEvent.inLogs(logs1, 'PaymentReleased', { to: payee1, amount: profit1 });
const tracker3 = await balance.tracker(payee3);
const { logs: logs3 } = await this.contract.release(payee3);
const profit3 = await tracker3.delta();
expect(profit3).to.be.bignumber.equal(ether('0.70'));
expectEvent.inLogs(logs3, 'PaymentReleased', { to: payee3, amount: profit3 });

const tracker2 = await balance.tracker(payee2);
const { logs: logs2 } = await this.contract.release(payee2);
const profit2 = await tracker2.delta();
expect(profit2).to.be.bignumber.equal(ether('0.10'));
expectEvent.inLogs(logs2, 'PaymentReleased', { to: payee2, amount: profit2 });
// end balance should be zero
expect(await balance.current(this.contract.address)).to.be.bignumber.equal('0');

const tracker3 = await balance.tracker(payee3);
const { logs: logs3 } = await this.contract.release(payee3);
const profit3 = await tracker3.delta();
expect(profit3).to.be.bignumber.equal(ether('0.70'));
expectEvent.inLogs(logs3, 'PaymentReleased', { to: payee3, amount: profit3 });
// check correct funds released accounting
expect(await this.contract.totalReleased()).to.be.bignumber.equal(initBalance);
});

// end balance should be zero
expect(await balance.current(this.contract.address)).to.be.bignumber.equal('0');
it('Token', async function () {
expect(await this.token.balanceOf(payee1)).to.be.bignumber.equal('0');
expect(await this.token.balanceOf(payee2)).to.be.bignumber.equal('0');
expect(await this.token.balanceOf(payee3)).to.be.bignumber.equal('0');

// check correct funds released accounting
expect(await this.contract.totalReleased()).to.be.bignumber.equal(initBalance);
await this.token.transfer(this.contract.address, amount, { from: owner });

expectEvent(
await this.contract.release(this.token.address, payee1),
'ERC20PaymentReleased',
{ token: this.token.address, to: payee1, amount: ether('0.20') },
);

await this.token.transfer(this.contract.address, amount, { from: owner });

expectEvent(
await this.contract.release(this.token.address, payee1),
'ERC20PaymentReleased',
{ token: this.token.address, to: payee1, amount: ether('0.20') },
);

expectEvent(
await this.contract.release(this.token.address, payee2),
'ERC20PaymentReleased',
{ token: this.token.address, to: payee2, amount: ether('0.20') },
);

expectEvent(
await this.contract.release(this.token.address, payee3),
'ERC20PaymentReleased',
{ token: this.token.address, to: payee3, amount: ether('1.40') },
);

expect(await this.token.balanceOf(payee1)).to.be.bignumber.equal(ether('0.40'));
expect(await this.token.balanceOf(payee2)).to.be.bignumber.equal(ether('0.20'));
expect(await this.token.balanceOf(payee3)).to.be.bignumber.equal(ether('1.40'));
});
});
});
});