diff --git a/contracts/finance/AbstractSplitter.sol b/contracts/finance/AbstractSplitter.sol new file mode 100644 index 00000000..7ac8620f --- /dev/null +++ b/contracts/finance/AbstractSplitter.sol @@ -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; + } +} diff --git a/contracts/mocks/ERC20Mock.sol b/contracts/mocks/ERC20Mock.sol new file mode 100644 index 00000000..801a840b --- /dev/null +++ b/contracts/mocks/ERC20Mock.sol @@ -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 {} diff --git a/contracts/mocks/PaymentSplitterMock.sol b/contracts/mocks/PaymentSplitterMock.sol new file mode 100644 index 00000000..91dd5549 --- /dev/null +++ b/contracts/mocks/PaymentSplitterMock.sol @@ -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); + } +} diff --git a/test/finance/AbstractSplitter.test.js b/test/finance/AbstractSplitter.test.js new file mode 100644 index 00000000..cc6778b7 --- /dev/null +++ b/test/finance/AbstractSplitter.test.js @@ -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]); + } + }); +});