Skip to content

Add AbstractSplitter #74

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

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions contracts/finance/AbstractSplitter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";

/**
* @title AbstractSplitter
* @dev This contract allows to split payments in any fungible asset among a group of accounts. The sender does not
* need to be aware that the asset will be split in this way, since it is handled transparently by the contract.
*
* The split can be in equal parts or in any other arbitrary proportion. The way this is specified is by assigning each
* account to a number of shares through the {_shares} and {_totalShares} virtual function. Of all the assets that this
* contract receives, each account will then be able to claim an amount proportional to the percentage of total shares
* they own assigned.
*
* `AbstractSplitter` 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.
*
* Warning: An abstractSplitter can only process a single asset class, implicitly defined by the {_balance} and
* {_doRelease} functions. Any other asset class will not be recoverable.
*/
abstract contract AbstractSplitter {
using SafeCast for *;

mapping(address account => int256) private _released;
int256 private _totalReleased;

event PaymentReleased(address to, uint256 amount);

/**
* @dev Internal hook: get shares for an account
*/
function _shares(address account) internal view virtual returns (uint256);

/**
* @dev Internal hook: get total shares
*/
function _totalShares() internal view virtual returns (uint256);

/**
* @dev Internal hook: get splitter balance
*/
function _balance() internal view virtual returns (uint256);

/**
* @dev Internal hook: call when token are released
*/
function _doRelease(address to, uint256 amount) internal virtual;

/**
* @dev Asset units up for release.
*/
function pendingRelease(address account) public view virtual returns (uint256) {
uint256 amount = _shares(account);
// if personalShares == 0, there is a risk of totalShares == 0. To avoid div by 0 just return 0
uint256 allocation = amount > 0 ? _allocation(amount, _totalShares()) : 0;
return (allocation.toInt256() - _released[account]).toUint256();
}

/**
* @dev Triggers a transfer of asset to `account` according to their percentage of the total shares and their
* previous withdrawals.
*/
function release(address account) public virtual returns (uint256) {
uint256 toRelease = pendingRelease(account);
if (toRelease > 0) {
_addRelease(account, toRelease.toInt256());
emit PaymentReleased(account, toRelease);
_doRelease(account, toRelease);
}
return toRelease;
}

/**
* @dev Update release manifest to account to shares movement when payment has not been released. This must be
* called whenever shares are minted, burned or transferred.
*/
function _beforeShareTransfer(address from, address to, uint256 amount) internal virtual {
if (amount > 0) {
uint256 supply = _totalShares();
if (supply > 0) {
int256 virtualRelease = _allocation(amount, supply).toInt256();
if (from != address(0)) _subRelease(from, virtualRelease);
if (to != address(0)) _addRelease(to, virtualRelease);
}
}
}

function _allocation(uint256 amount, uint256 supply) private view returns (uint256) {
return Math.mulDiv(amount, (_balance().toInt256() + _totalReleased).toUint256(), supply);
}

function _addRelease(address account, int256 amount) private {
_released[account] += amount;
_totalReleased += amount;
}

function _subRelease(address account, int256 amount) private {
_released[account] -= amount;
_totalReleased -= amount;
}
}
7 changes: 7 additions & 0 deletions contracts/mocks/ERC20Mock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

abstract contract ERC20Mock is ERC20 {}
48 changes: 48 additions & 0 deletions contracts/mocks/PaymentSplitterMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.20;

import {IERC20, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {AbstractSplitter} from "../finance/AbstractSplitter.sol";

abstract contract PaymentSplitterMock is AbstractSplitter, ERC20 {
IERC20 public immutable token;

constructor(IERC20 _token) {
token = _token;
}

/**
* @dev Internal hook: shares are represented as ERC20 tokens
*/
function _shares(address account) internal view virtual override returns (uint256) {
return balanceOf(account);
}

/**
* @dev Internal hook: get total shares
*/
function _totalShares() internal view virtual override returns (uint256) {
return totalSupply();
}

/**
* @dev Internal hook: get splitter balance
*/
function _balance() internal view virtual override returns (uint256) {
return token.balanceOf(address(this));
}

/**
* @dev Internal hook: call when token are released
*/
function _doRelease(address to, uint256 amount) internal virtual override {
SafeERC20.safeTransfer(token, to, amount);
}

function _update(address from, address to, uint256 amount) internal virtual override {
_beforeShareTransfer(from, to, amount);
super._update(from, to, amount);
}
}
142 changes: 142 additions & 0 deletions test/finance/AbstractSplitter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
const { ethers } = require('hardhat');
const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { sum } = require('@openzeppelin/contracts/test/helpers/math');

const amount = ethers.parseEther('1');

async function fixture() {
const [owner, payee1, payee2, payee3] = await ethers.getSigners();

const token = await ethers.deployContract('$ERC20Mock', ['name', 'symbol']);
const mock = await ethers.deployContract('$PaymentSplitterMock', ['splitter name', 'splitter symbol', token]);

return {
owner,
payee1,
payee2,
payee3,
token,
mock,
};
}

describe('TokenizedERC20Splitter', function () {
beforeEach(async function () {
Object.assign(this, await loadFixture(fixture));
});

it('set payee before receive', async function () {
await this.mock.$_mint(this.payee1, 1);
await this.token.$_mint(this.mock, amount);

const tx = this.mock.release(this.payee1);
await expect(tx).to.emit(this.mock, 'PaymentReleased').withArgs(this.payee1, amount);
await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.payee1], [-amount, amount]);
});

it('set payee after receive', async function () {
await this.token.$_mint(this.mock, amount);
await this.mock.$_mint(this.payee1, 1);

const tx = this.mock.release(this.payee1);
await expect(tx).to.emit(this.mock, 'PaymentReleased').withArgs(this.payee1, amount);
await expect(tx).to.changeTokenBalances(this.token, [this.mock, this.payee1], [-amount, amount]);
});

it('multiple payees', async function () {
const manifest = [
{ account: this.payee1, shares: 20n },
{ account: this.payee2, shares: 10n },
{ account: this.payee3, shares: 70n },
];
const total = sum(...manifest.map(({ shares }) => shares));

// setup
await Promise.all(manifest.map(({ account, shares }) => this.mock.$_mint(account, shares)));
await this.token.$_mint(this.mock, amount);

// distribute to payees
for (const { account, shares } of manifest) {
const profit = (amount * shares) / total;

await expect(this.mock.pendingRelease(account)).to.eventually.equal(profit);

const tx = this.mock.release(account);
await expect(tx).to.emit(this.mock, 'PaymentReleased').withArgs(account, profit);
await expect(tx).to.changeTokenBalances(this.token, [this.mock, account], [-profit, profit]);
}

// check correct funds released accounting
await expect(this.token.balanceOf(this.mock)).to.eventually.equal(0n);
});

it('multiple payees with varying shares', async function () {
const manifest = Object.fromEntries(
[
{ account: this.payee1, shares: 0n, pending: 0n },
{ account: this.payee2, shares: 0n, pending: 0n },
{ account: this.payee3, shares: 0n, pending: 0n },
].map(value => [value.account.address, value]),
);

const runCheck = () =>
Promise.all(
Object.values(manifest).map(async ({ account, shares, pending }) => {
await expect(this.mock.balanceOf(account)).to.eventually.equal(shares);
await expect(this.mock.pendingRelease(account)).to.eventually.equal(pending);
}),
);

await runCheck();

await this.mock.$_mint(this.payee1, 100n);
await this.mock.$_mint(this.payee2, 100n);
manifest[this.payee1.address].shares += 100n;
manifest[this.payee2.address].shares += 100n;
await runCheck();

await this.token.$_mint(this.mock, 100n);
manifest[this.payee1.address].pending += 50n; // 50% of 100
manifest[this.payee2.address].pending += 50n; // 50% of 100
await runCheck();

await this.mock.$_mint(this.payee1, 100n);
await this.mock.$_mint(this.payee3, 100n);
manifest[this.payee1.address].shares += 100n;
manifest[this.payee3.address].shares += 100n;
await runCheck();

await this.token.$_mint(this.mock, 100n);
manifest[this.payee1.address].pending += 50n; // 50% of 100
manifest[this.payee2.address].pending += 25n; // 25% of 100
manifest[this.payee3.address].pending += 25n; // 25% of 100
await runCheck();

await this.mock.$_burn(this.payee1, 200n);
manifest[this.payee1.address].shares -= 200n;
await runCheck();

await this.token.$_mint(this.mock, 100n);
manifest[this.payee2.address].pending += 50n; // 50% of 100
manifest[this.payee3.address].pending += 50n; // 50% of 100
await runCheck();

await this.mock.$_transfer(this.payee2, this.payee3, 40n);
manifest[this.payee2.address].shares -= 40n;
manifest[this.payee3.address].shares += 40n;
await runCheck();

await this.token.$_mint(this.mock, 100n);
manifest[this.payee2.address].pending += 30n; // 30% of 100
manifest[this.payee3.address].pending += 70n; // 70% of 100
await runCheck();

// do all releases
for (const { account, pending } of Object.values(manifest)) {
const tx = this.mock.release(account);
await expect(tx).to.emit(this.mock, 'PaymentReleased').withArgs(account, pending);
await expect(tx).to.changeTokenBalances(this.token, [this.mock, account], [-pending, pending]);
}
});
});