Skip to content

Commit

Permalink
ERC20 Snapshot Impl #2 (#1617)
Browse files Browse the repository at this point in the history
* ✏️ 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
  • Loading branch information
mswezey23 authored and frangio committed Feb 6, 2019
1 parent 2c34cfb commit 40d1514
Show file tree
Hide file tree
Showing 5 changed files with 351 additions and 2 deletions.
4 changes: 2 additions & 2 deletions .gitignore
Expand Up @@ -32,8 +32,8 @@ npm-debug.log
# truffle build directory
build/

# lol macs
.DS_Store/
# macOS
.DS_Store

# truffle
.node-xmlhttprequest-*
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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.
Expand Down
133 changes: 133 additions & 0 deletions 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 <info@validitylabs.org>
*/
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];
}
}
}
18 changes: 18 additions & 0 deletions 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);
}
}
197 changes: 197 additions & 0 deletions 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)
);
}
});
});
});
});
});
});

0 comments on commit 40d1514

Please sign in to comment.