diff --git a/contracts/drafts/ERC20Migrator.sol b/contracts/drafts/ERC20Migrator.sol new file mode 100644 index 000000000..69e745f14 --- /dev/null +++ b/contracts/drafts/ERC20Migrator.sol @@ -0,0 +1,101 @@ +pragma solidity ^0.4.24; + +import "../token/ERC20/IERC20.sol"; +import "../token/ERC20/ERC20Mintable.sol"; +import "../token/ERC20/SafeERC20.sol"; +import "../math/Math.sol"; + + +/** + * @title ERC20Migrator + * @dev This contract can be used to migrate an ERC20 token from one + * contract to another, where each token holder has to opt-in to the migration. + * To opt-in, users must approve for this contract the number of tokens they + * want to migrate. Once the allowance is set up, anyone can trigger the + * migration to the new token contract. In this way, token holders "turn in" + * their old balance and will be minted an equal amount in the new token. + * The new token contract must be mintable. For the precise interface refer to + * OpenZeppelin's ERC20Mintable, but the only functions that are needed are + * `isMinter(address)` and `mint(address, amount)`. The migrator will check + * that it is a minter for the token. + * The balance from the legacy token will be transfered to the migrator, as it + * is migrated, and remain there forever. + * Although this contract can be used in many different scenarios, the main + * motivation was to provide a way to migrate ERC20 tokens into an upgradeable + * version of it using ZeppelinOS. To read more about how this can be done + * using this implementation, please follow the official documentation site of + * ZeppelinOS: https://docs.zeppelinos.org/docs/erc20_onboarding.html + * Example of usage: + * ``` + * const migrator = await ERC20Migrator.new(legacyToken.address); + * await newToken.addMinter(migrator.address); + * await migrator.beginMigration(newToken.address); + * ``` + */ +contract ERC20Migrator { + using SafeERC20 for IERC20; + + /// Address of the old token contract + IERC20 private _legacyToken; + + /// Address of the new token contract + ERC20Mintable private _newToken; + + /** + * @param legacyToken address of the old token contract + */ + constructor(IERC20 legacyToken) public { + require(legacyToken != address(0)); + _legacyToken = legacyToken; + } + + /** + * @dev Returns the legacy token that is being migrated. + */ + function legacyToken() public view returns (IERC20) { + return _legacyToken; + } + + /** + * @dev Returns the new token to which we are migrating. + */ + function newToken() public view returns (IERC20) { + return _newToken; + } + + /** + * @dev Begins the migration by setting which is the new token that will be + * minted. This contract must be a minter for the new token. + * @param newToken the token that will be minted + */ + function beginMigration(ERC20Mintable newToken) public { + require(_newToken == address(0)); + require(newToken != address(0)); + require(newToken.isMinter(this)); + + _newToken = newToken; + } + + /** + * @dev Transfers part of an account's balance in the old token to this + * contract, and mints the same amount of new tokens for that account. + * @param account whose tokens will be migrated + * @param amount amount of tokens to be migrated + */ + function migrate(address account, uint256 amount) public { + _legacyToken.safeTransferFrom(account, this, amount); + _newToken.mint(account, amount); + } + + /** + * @dev Transfers all of an account's allowed balance in the old token to + * this contract, and mints the same amount of new tokens for that account. + * @param account whose tokens will be migrated + */ + function migrateAll(address account) public { + uint256 balance = _legacyToken.balanceOf(account); + uint256 allowance = _legacyToken.allowance(account, this); + uint256 amount = Math.min(balance, allowance); + migrate(account, amount); + } +} diff --git a/test/drafts/ERC20Migrator.test.js b/test/drafts/ERC20Migrator.test.js new file mode 100644 index 000000000..315a7ea78 --- /dev/null +++ b/test/drafts/ERC20Migrator.test.js @@ -0,0 +1,156 @@ +const { assertRevert } = require('../helpers/assertRevert'); + +const ERC20Mock = artifacts.require('ERC20Mock'); +const ERC20Mintable = artifacts.require('ERC20Mintable'); +const ERC20Migrator = artifacts.require('ERC20Migrator'); + +const BigNumber = web3.eth.BigNumber; + +require('chai') + .use(require('chai-bignumber')(BigNumber)) + .should(); + +contract('ERC20Migrator', function ([_, owner, recipient, anotherAccount]) { + const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + + const totalSupply = 200; + + it('reverts with a null legacy token address', async function () { + await assertRevert(ERC20Migrator.new(ZERO_ADDRESS)); + }); + + describe('with tokens and migrator', function () { + beforeEach('deploying tokens and migrator', async function () { + this.legacyToken = await ERC20Mock.new(owner, totalSupply); + this.migrator = await ERC20Migrator.new(this.legacyToken.address); + this.newToken = await ERC20Mintable.new(); + }); + + it('returns legacy token', async function () { + (await this.migrator.legacyToken()).should.be.equal(this.legacyToken.address); + }); + + describe('beginMigration', function () { + it('reverts with a null new token address', async function () { + await assertRevert(this.migrator.beginMigration(ZERO_ADDRESS)); + }); + + it('reverts if not a minter of the token', async function () { + await assertRevert(this.migrator.beginMigration(this.newToken.address)); + }); + + it('succeeds if it is a minter of the token', async function () { + await this.newToken.addMinter(this.migrator.address); + await this.migrator.beginMigration(this.newToken.address); + }); + + it('reverts the second time it is called', async function () { + await this.newToken.addMinter(this.migrator.address); + await this.migrator.beginMigration(this.newToken.address); + await assertRevert(this.migrator.beginMigration(this.newToken.address)); + }); + }); + + describe('once migration began', function () { + beforeEach('beginning migration', async function () { + await this.newToken.addMinter(this.migrator.address); + await this.migrator.beginMigration(this.newToken.address); + }); + + it('returns new token', async function () { + (await this.migrator.newToken()).should.be.equal(this.newToken.address); + }); + + describe('migrateAll', function () { + const baseAmount = totalSupply; + + describe('when the approved balance is equal to the owned balance', function () { + const amount = baseAmount; + + beforeEach('approving the whole balance to the new contract', async function () { + await this.legacyToken.approve(this.migrator.address, amount, { from: owner }); + }); + + beforeEach('migrating token', async function () { + const tx = await this.migrator.migrateAll(owner); + this.logs = tx.receipt.logs; + }); + + it('mints the same balance of the new token', async function () { + const currentBalance = await this.newToken.balanceOf(owner); + currentBalance.should.be.bignumber.equal(amount); + }); + + it('burns a given amount of old tokens', async function () { + const currentBurnedBalance = await this.legacyToken.balanceOf(this.migrator.address); + currentBurnedBalance.should.be.bignumber.equal(amount); + + const currentLegacyTokenBalance = await this.legacyToken.balanceOf(owner); + currentLegacyTokenBalance.should.be.bignumber.equal(0); + }); + + it('updates the total supply', async function () { + const currentSupply = await this.newToken.totalSupply(); + currentSupply.should.be.bignumber.equal(amount); + }); + }); + + describe('when the approved balance is lower than the owned balance', function () { + const amount = baseAmount - 1; + + beforeEach('approving part of the balance to the new contract', async function () { + await this.legacyToken.approve(this.migrator.address, amount, { from: owner }); + await this.migrator.migrateAll(owner); + }); + + it('migrates only approved amount', async function () { + const currentBalance = await this.newToken.balanceOf(owner); + currentBalance.should.be.bignumber.equal(amount); + }); + }); + }); + + describe('migrate', function () { + const baseAmount = 50; + + beforeEach('approving tokens to the new contract', async function () { + await this.legacyToken.approve(this.migrator.address, baseAmount, { from: owner }); + }); + + describe('when the amount is equal to the one approved', function () { + const amount = baseAmount; + + beforeEach('migrate token', async function () { + ({ logs: this.logs } = await this.migrator.migrate(owner, amount)); + }); + + it('mints that amount of the new token', async function () { + const currentBalance = await this.newToken.balanceOf(owner); + currentBalance.should.be.bignumber.equal(amount); + }); + + it('burns a given amount of old tokens', async function () { + const currentBurnedBalance = await this.legacyToken.balanceOf(this.migrator.address); + currentBurnedBalance.should.be.bignumber.equal(amount); + + const currentLegacyTokenBalance = await this.legacyToken.balanceOf(owner); + currentLegacyTokenBalance.should.be.bignumber.equal(totalSupply - amount); + }); + + it('updates the total supply', async function () { + const currentSupply = await this.newToken.totalSupply(); + currentSupply.should.be.bignumber.equal(amount); + }); + }); + + describe('when the given amount is higher than the one approved', function () { + const amount = baseAmount + 1; + + it('reverts', async function () { + await assertRevert(this.migrator.migrate(owner, amount)); + }); + }); + }); + }); + }); +}); diff --git a/test/helpers/expectEvent.js b/test/helpers/expectEvent.js index 565d61fe5..37fa1083e 100644 --- a/test/helpers/expectEvent.js +++ b/test/helpers/expectEvent.js @@ -1,12 +1,24 @@ const should = require('chai').should(); function inLogs (logs, eventName, eventArgs = {}) { - const event = logs.find(e => e.event === eventName); + const event = logs.find(function (e) { + if (e.event === eventName) { + let matches = true; + + for (const [k, v] of Object.entries(eventArgs)) { + if (e.args[k] !== v) { + matches = false; + } + } + + if (matches) { + return true; + } + } + }); + should.exist(event); - for (const [k, v] of Object.entries(eventArgs)) { - should.exist(event.args[k]); - event.args[k].should.equal(v); - } + return event; } diff --git a/test/token/ERC20/ERC20Burnable.test.js b/test/token/ERC20/ERC20Burnable.test.js index 0e18e7867..6c912a534 100644 --- a/test/token/ERC20/ERC20Burnable.test.js +++ b/test/token/ERC20/ERC20Burnable.test.js @@ -1,4 +1,4 @@ -const { shouldBehaveLikeERC20Burnable } = require('./ERC20Burnable.behavior'); +const { shouldBehaveLikeERC20Burnable } = require('./behaviors/ERC20Burnable.behavior'); const ERC20BurnableMock = artifacts.require('ERC20BurnableMock'); contract('ERC20Burnable', function ([_, owner, ...otherAccounts]) { diff --git a/test/token/ERC20/ERC20Capped.test.js b/test/token/ERC20/ERC20Capped.test.js index 7e0c35c13..45558a162 100644 --- a/test/token/ERC20/ERC20Capped.test.js +++ b/test/token/ERC20/ERC20Capped.test.js @@ -1,7 +1,7 @@ const { assertRevert } = require('../../helpers/assertRevert'); const { ether } = require('../../helpers/ether'); -const { shouldBehaveLikeERC20Mintable } = require('./ERC20Mintable.behavior'); -const { shouldBehaveLikeERC20Capped } = require('./ERC20Capped.behavior'); +const { shouldBehaveLikeERC20Mintable } = require('./behaviors/ERC20Mintable.behavior'); +const { shouldBehaveLikeERC20Capped } = require('./behaviors/ERC20Capped.behavior'); const ERC20Capped = artifacts.require('ERC20Capped'); diff --git a/test/token/ERC20/ERC20Mintable.test.js b/test/token/ERC20/ERC20Mintable.test.js index 9dca0e558..083565d87 100644 --- a/test/token/ERC20/ERC20Mintable.test.js +++ b/test/token/ERC20/ERC20Mintable.test.js @@ -1,4 +1,4 @@ -const { shouldBehaveLikeERC20Mintable } = require('./ERC20Mintable.behavior'); +const { shouldBehaveLikeERC20Mintable } = require('./behaviors/ERC20Mintable.behavior'); const ERC20MintableMock = artifacts.require('ERC20MintableMock'); const { shouldBehaveLikePublicRole } = require('../../access/roles/PublicRole.behavior'); diff --git a/test/token/ERC20/ERC20Burnable.behavior.js b/test/token/ERC20/behaviors/ERC20Burnable.behavior.js similarity index 96% rename from test/token/ERC20/ERC20Burnable.behavior.js rename to test/token/ERC20/behaviors/ERC20Burnable.behavior.js index be81ed63b..cf0190046 100644 --- a/test/token/ERC20/ERC20Burnable.behavior.js +++ b/test/token/ERC20/behaviors/ERC20Burnable.behavior.js @@ -1,5 +1,5 @@ -const { assertRevert } = require('../../helpers/assertRevert'); -const expectEvent = require('../../helpers/expectEvent'); +const { assertRevert } = require('../../../helpers/assertRevert'); +const expectEvent = require('../../../helpers/expectEvent'); const BigNumber = web3.BigNumber; const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; diff --git a/test/token/ERC20/ERC20Capped.behavior.js b/test/token/ERC20/behaviors/ERC20Capped.behavior.js similarity index 89% rename from test/token/ERC20/ERC20Capped.behavior.js rename to test/token/ERC20/behaviors/ERC20Capped.behavior.js index e56195d35..a22a99632 100644 --- a/test/token/ERC20/ERC20Capped.behavior.js +++ b/test/token/ERC20/behaviors/ERC20Capped.behavior.js @@ -1,4 +1,5 @@ -const { expectThrow } = require('../../helpers/expectThrow'); +const { expectThrow } = require('../../../helpers/expectThrow'); +const expectEvent = require('../../../helpers/expectEvent'); const BigNumber = web3.BigNumber; diff --git a/test/token/ERC20/ERC20Mintable.behavior.js b/test/token/ERC20/behaviors/ERC20Mintable.behavior.js similarity index 97% rename from test/token/ERC20/ERC20Mintable.behavior.js rename to test/token/ERC20/behaviors/ERC20Mintable.behavior.js index 5cf43696d..5046fc62e 100644 --- a/test/token/ERC20/ERC20Mintable.behavior.js +++ b/test/token/ERC20/behaviors/ERC20Mintable.behavior.js @@ -1,5 +1,5 @@ -const { assertRevert } = require('../../helpers/assertRevert'); -const expectEvent = require('../../helpers/expectEvent'); +const { assertRevert } = require('../../../helpers/assertRevert'); +const expectEvent = require('../../../helpers/expectEvent'); const BigNumber = web3.BigNumber;