From 40d15146c46c0b26960e1eae0c0b27bf8258a729 Mon Sep 17 00:00:00 2001 From: Matt Swezey Date: Wed, 6 Feb 2019 13:26:59 -0600 Subject: [PATCH] ERC20 Snapshot Impl #2 (#1617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✏️ Refactor code & Refork OZ Repo * Refactor ERC20Snapshot to use on-demand snapshots. * Add ERC20Snapshot changelog entry. * Move ERC20Snapshot to drafts. * Improve changelog entry. * Make snapshot tests clearer. * Refactor ERC20Snapshots to use Counters. * Refactor snapshot arrays into a struct. * Remove .DS_Store files. * Delete yarn.lock * Fix linter error. * simplify gitignore entry --- .gitignore | 4 +- CHANGELOG.md | 1 + contracts/drafts/ERC20Snapshot.sol | 133 +++++++++++++++++ contracts/mocks/ERC20SnapshotMock.sol | 18 +++ test/drafts/ERC20Snapshot.test.js | 197 ++++++++++++++++++++++++++ 5 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 contracts/drafts/ERC20Snapshot.sol create mode 100644 contracts/mocks/ERC20SnapshotMock.sol create mode 100644 test/drafts/ERC20Snapshot.test.js diff --git a/.gitignore b/.gitignore index 7c53c0042a9..a93fffc04f9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,8 @@ npm-debug.log # truffle build directory build/ -# lol macs -.DS_Store/ +# macOS +.DS_Store # truffle .node-xmlhttprequest-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 92a43883bf8..877a22d5188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### New features: * `ERC20`: added internal `_approve(address owner, address spender, uint256 value)`, allowing derived contracts to set the allowance of arbitrary accounts. * `ERC20Metadata`: added internal `_setTokenURI(string memory tokenURI)`. + * `ERC20Snapshot`: create snapshots on demand of the token balances and total supply, to later retrieve and e.g. calculate dividends at a past time. ### Improvements: * Upgraded the minimum compiler version to v0.5.2: this removes many Solidity warnings that were false positives. diff --git a/contracts/drafts/ERC20Snapshot.sol b/contracts/drafts/ERC20Snapshot.sol new file mode 100644 index 00000000000..44b39091d32 --- /dev/null +++ b/contracts/drafts/ERC20Snapshot.sol @@ -0,0 +1,133 @@ +pragma solidity ^0.5.2; + +import "../math/SafeMath.sol"; +import "../utils/Arrays.sol"; +import "../drafts/Counters.sol"; +import "../token/ERC20/ERC20.sol"; + +/** + * @title ERC20 token with snapshots. + * inspired by Jordi Baylina's MiniMeToken to record historical balances + * Snapshots store a value at the time a snapshot is taken (and a new snapshot id created), and the corresponding + * snapshot id. Each account has individual snapshots taken on demand, as does the token's total supply. + * @author Validity Labs AG + */ +contract ERC20Snapshot is ERC20 { + using SafeMath for uint256; + using Arrays for uint256[]; + using Counters for Counters.Counter; + + // Snapshoted values have arrays of ids and the value corresponding to that id. These could be an array of a + // Snapshot struct, but that would impede usage of functions that work on an array. + struct Snapshots { + uint256[] ids; + uint256[] values; + } + + mapping (address => Snapshots) private _accountBalanceSnapshots; + Snapshots private _totalSupplySnaphots; + + // Snapshot ids increase monotonically, with the first value being 1. An id of 0 is invalid. + Counters.Counter private _currentSnapshotId; + + event Snapshot(uint256 id); + + // Creates a new snapshot id. Balances are only stored in snapshots on demand: unless a snapshot was taken, a + // balance change will not be recorded. This means the extra added cost of storing snapshotted balances is only paid + // when required, but is also flexible enough that it allows for e.g. daily snapshots. + function snapshot() public returns (uint256) { + _currentSnapshotId.increment(); + + uint256 currentId = _currentSnapshotId.current(); + emit Snapshot(currentId); + return currentId; + } + + function balanceOfAt(address account, uint256 snapshotId) public view returns (uint256) { + (bool snapshotted, uint256 value) = _valueAt(snapshotId, _accountBalanceSnapshots[account]); + + return snapshotted ? value : balanceOf(account); + } + + function totalSupplyAt(uint256 snapshotId) public view returns(uint256) { + (bool snapshotted, uint256 value) = _valueAt(snapshotId, _totalSupplySnaphots); + + return snapshotted ? value : totalSupply(); + } + + // _transfer, _mint and _burn are the only functions where the balances are modified, so it is there that the + // snapshots are updated. Note that the update happens _before_ the balance change, with the pre-modified value. + // The same is true for the total supply and _mint and _burn. + function _transfer(address from, address to, uint256 value) internal { + _updateAccountSnapshot(from); + _updateAccountSnapshot(to); + + super._transfer(from, to, value); + } + + function _mint(address account, uint256 value) internal { + _updateAccountSnapshot(account); + _updateTotalSupplySnapshot(); + + super._mint(account, value); + } + + function _burn(address account, uint256 value) internal { + _updateAccountSnapshot(account); + _updateTotalSupplySnapshot(); + + super._burn(account, value); + } + + // When a valid snapshot is queried, there are three possibilities: + // a) The queried value was not modified after the snapshot was taken. Therefore, a snapshot entry was never + // created for this id, and all stored snapshot ids are smaller than the requested one. The value that corresponds + // to this id is the current one. + // b) The queried value was modified after the snapshot was taken. Therefore, there will be an entry with the + // requested id, and its value is the one to return. + // c) More snapshots were created after the requested one, and the queried value was later modified. There will be + // no entry for the requested id: the value that corresponds to it is that of the smallest snapshot id that is + // larger than the requested one. + // + // In summary, we need to find an element in an array, returning the index of the smallest value that is larger if + // it is not found, unless said value doesn't exist (e.g. when all values are smaller). Arrays.findUpperBound does + // exactly this. + function _valueAt(uint256 snapshotId, Snapshots storage snapshots) + private view returns (bool, uint256) + { + require(snapshotId > 0); + require(snapshotId <= _currentSnapshotId.current()); + + uint256 index = snapshots.ids.findUpperBound(snapshotId); + + if (index == snapshots.ids.length) { + return (false, 0); + } else { + return (true, snapshots.values[index]); + } + } + + function _updateAccountSnapshot(address account) private { + _updateSnapshot(_accountBalanceSnapshots[account], balanceOf(account)); + } + + function _updateTotalSupplySnapshot() private { + _updateSnapshot(_totalSupplySnaphots, totalSupply()); + } + + function _updateSnapshot(Snapshots storage snapshots, uint256 currentValue) private { + uint256 currentId = _currentSnapshotId.current(); + if (_lastSnapshotId(snapshots.ids) < currentId) { + snapshots.ids.push(currentId); + snapshots.values.push(currentValue); + } + } + + function _lastSnapshotId(uint256[] storage ids) private view returns (uint256) { + if (ids.length == 0) { + return 0; + } else { + return ids[ids.length - 1]; + } + } +} diff --git a/contracts/mocks/ERC20SnapshotMock.sol b/contracts/mocks/ERC20SnapshotMock.sol new file mode 100644 index 00000000000..08b4140d178 --- /dev/null +++ b/contracts/mocks/ERC20SnapshotMock.sol @@ -0,0 +1,18 @@ +pragma solidity ^0.5.2; + +import "../drafts/ERC20Snapshot.sol"; + + +contract ERC20SnapshotMock is ERC20Snapshot { + constructor(address initialAccount, uint256 initialBalance) public { + _mint(initialAccount, initialBalance); + } + + function mint(address account, uint256 amount) public { + _mint(account, amount); + } + + function burn(address account, uint256 amount) public { + _burn(account, amount); + } +} diff --git a/test/drafts/ERC20Snapshot.test.js b/test/drafts/ERC20Snapshot.test.js new file mode 100644 index 00000000000..0d327b39ec0 --- /dev/null +++ b/test/drafts/ERC20Snapshot.test.js @@ -0,0 +1,197 @@ +const { BN, expectEvent, shouldFail } = require('openzeppelin-test-helpers'); +const ERC20SnapshotMock = artifacts.require('ERC20SnapshotMock'); + +contract('ERC20Snapshot', function ([_, initialHolder, recipient, anyone]) { + const initialSupply = new BN(100); + + beforeEach(async function () { + this.token = await ERC20SnapshotMock.new(initialHolder, initialSupply); + }); + + describe('snapshot', function () { + it('emits a snapshot event', async function () { + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot'); + }); + + it('creates increasing snapshots ids, starting from 1', async function () { + for (const id of ['1', '2', '3', '4', '5']) { + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot', { id }); + } + }); + }); + + describe('totalSupplyAt', function () { + it('reverts with a snapshot id of 0', async function () { + await shouldFail.reverting(this.token.totalSupplyAt(0)); + }); + + it('reverts with a not-yet-created snapshot id', async function () { + await shouldFail.reverting(this.token.totalSupplyAt(1)); + }); + + context('with initial snapshot', function () { + beforeEach(async function () { + this.initialSnapshotId = new BN('1'); + + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot', { id: this.initialSnapshotId }); + }); + + context('with no supply changes after the snapshot', function () { + it('returns the current total supply', async function () { + (await this.token.totalSupplyAt(this.initialSnapshotId)).should.be.bignumber.equal(initialSupply); + }); + }); + + context('with supply changes after the snapshot', function () { + beforeEach(async function () { + await this.token.mint(anyone, new BN('50')); + await this.token.burn(initialHolder, new BN('20')); + }); + + it('returns the total supply before the changes', async function () { + (await this.token.totalSupplyAt(this.initialSnapshotId)).should.be.bignumber.equal(initialSupply); + }); + + context('with a second snapshot after supply changes', function () { + beforeEach(async function () { + this.secondSnapshotId = new BN('2'); + + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot', { id: this.secondSnapshotId }); + }); + + it('snapshots return the supply before and after the changes', async function () { + (await this.token.totalSupplyAt(this.initialSnapshotId)).should.be.bignumber.equal(initialSupply); + + (await this.token.totalSupplyAt(this.secondSnapshotId)).should.be.bignumber.equal( + await this.token.totalSupply() + ); + }); + }); + + context('with multiple snapshots after supply changes', function () { + beforeEach(async function () { + this.secondSnapshotIds = ['2', '3', '4']; + + for (const id of this.secondSnapshotIds) { + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot', { id }); + } + }); + + it('all posterior snapshots return the supply after the changes', async function () { + (await this.token.totalSupplyAt(this.initialSnapshotId)).should.be.bignumber.equal(initialSupply); + + const currentSupply = await this.token.totalSupply(); + + for (const id of this.secondSnapshotIds) { + (await this.token.totalSupplyAt(id)).should.be.bignumber.equal(currentSupply); + } + }); + }); + }); + }); + }); + + describe('balanceOfAt', function () { + it('reverts with a snapshot id of 0', async function () { + await shouldFail.reverting(this.token.balanceOfAt(anyone, 0)); + }); + + it('reverts with a not-yet-created snapshot id', async function () { + await shouldFail.reverting(this.token.balanceOfAt(anyone, 1)); + }); + + context('with initial snapshot', function () { + beforeEach(async function () { + this.initialSnapshotId = new BN('1'); + + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot', { id: this.initialSnapshotId }); + }); + + context('with no balance changes after the snapshot', function () { + it('returns the current balance for all accounts', async function () { + (await this.token.balanceOfAt(initialHolder, this.initialSnapshotId)) + .should.be.bignumber.equal(initialSupply); + (await this.token.balanceOfAt(recipient, this.initialSnapshotId)).should.be.bignumber.equal('0'); + (await this.token.balanceOfAt(anyone, this.initialSnapshotId)).should.be.bignumber.equal('0'); + }); + }); + + context('with balance changes after the snapshot', function () { + beforeEach(async function () { + await this.token.transfer(recipient, new BN('10'), { from: initialHolder }); + await this.token.mint(recipient, new BN('50')); + await this.token.burn(initialHolder, new BN('20')); + }); + + it('returns the balances before the changes', async function () { + (await this.token.balanceOfAt(initialHolder, this.initialSnapshotId)) + .should.be.bignumber.equal(initialSupply); + (await this.token.balanceOfAt(recipient, this.initialSnapshotId)).should.be.bignumber.equal('0'); + (await this.token.balanceOfAt(anyone, this.initialSnapshotId)).should.be.bignumber.equal('0'); + }); + + context('with a second snapshot after supply changes', function () { + beforeEach(async function () { + this.secondSnapshotId = new BN('2'); + + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot', { id: this.secondSnapshotId }); + }); + + it('snapshots return the balances before and after the changes', async function () { + (await this.token.balanceOfAt(initialHolder, this.initialSnapshotId)) + .should.be.bignumber.equal(initialSupply); + (await this.token.balanceOfAt(recipient, this.initialSnapshotId)).should.be.bignumber.equal('0'); + (await this.token.balanceOfAt(anyone, this.initialSnapshotId)).should.be.bignumber.equal('0'); + + (await this.token.balanceOfAt(initialHolder, this.secondSnapshotId)).should.be.bignumber.equal( + await this.token.balanceOf(initialHolder) + ); + (await this.token.balanceOfAt(recipient, this.secondSnapshotId)).should.be.bignumber.equal( + await this.token.balanceOf(recipient) + ); + (await this.token.balanceOfAt(anyone, this.secondSnapshotId)).should.be.bignumber.equal( + await this.token.balanceOf(anyone) + ); + }); + }); + + context('with multiple snapshots after supply changes', function () { + beforeEach(async function () { + this.secondSnapshotIds = ['2', '3', '4']; + + for (const id of this.secondSnapshotIds) { + const { logs } = await this.token.snapshot(); + expectEvent.inLogs(logs, 'Snapshot', { id }); + } + }); + + it('all posterior snapshots return the supply after the changes', async function () { + (await this.token.balanceOfAt(initialHolder, this.initialSnapshotId)) + .should.be.bignumber.equal(initialSupply); + (await this.token.balanceOfAt(recipient, this.initialSnapshotId)).should.be.bignumber.equal('0'); + (await this.token.balanceOfAt(anyone, this.initialSnapshotId)).should.be.bignumber.equal('0'); + + for (const id of this.secondSnapshotIds) { + (await this.token.balanceOfAt(initialHolder, id)).should.be.bignumber.equal( + await this.token.balanceOf(initialHolder) + ); + (await this.token.balanceOfAt(recipient, id)).should.be.bignumber.equal( + await this.token.balanceOf(recipient) + ); + (await this.token.balanceOfAt(anyone, id)).should.be.bignumber.equal( + await this.token.balanceOf(anyone) + ); + } + }); + }); + }); + }); + }); +});