瀏覽代碼

Add ERC20 opt-in migration contract (#1054)

* Extract standard token behaviuor to reuse it in other tests

* Add opt in ERC20 migration contract

* Make migration contract not to depend from standard token

* Changes based on feedback

* Improve MigratableERC20 inline documentation

* move behaviors to behaviors directory

* refactor MigratableERC20 into ERC20Migrator

* fix errors

* change expectEvent to support multiple events with same name

* fix tests

* update documentation

* rename MigratableERC20 files to ERC20Migrator

* move to drafts

* test beginMigration

* rename to ERC20Migrator

* missing semicolon  (╯°□°)╯︵ ┻━┻

* add non-zero check

* improve documentation based on review comments

* improve test descriptions

* improve docs

* add getters

* fix contract

* improve tests
Facundo Spagnuolo 7 年之前
父節點
當前提交
92133be7ea

+ 101 - 0
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);
+  }
+}

+ 156 - 0
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));
+          });
+        });
+      });
+    });
+  });
+});

+ 17 - 5
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;
 }
 

+ 1 - 1
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]) {

+ 2 - 2
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');
 

+ 1 - 1
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');
 

+ 2 - 2
test/token/ERC20/ERC20Burnable.behavior.js → 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';

+ 2 - 1
test/token/ERC20/ERC20Capped.behavior.js → 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;
 

+ 2 - 2
test/token/ERC20/ERC20Mintable.behavior.js → 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;