Просмотр исходного кода

Wrapper extension for ERC20 token (#2633)

Co-authored-by: Francisco Giordano <frangio.1@gmail.com>
Hadrien Croubois 4 лет назад
Родитель
Сommit
6842518b1b

+ 1 - 0
CHANGELOG.md

@@ -4,6 +4,7 @@
 
  * `ERC20Votes`: add a new extension of the `ERC20` token with support for voting snapshots and delegation. ([#2632](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2632))
  * `ERC20VotesComp`: Variant of `ERC20Votes` that is compatible with Compound's `Comp` token interface but restricts supply to `uint96`. ([#2706](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2706))
+ * `ERC20Wrapper`: add a new extension of the `ERC20` token which wraps an underlying token. Deposit and withdraw guarantee that the total supply is backed by a corresponding amount of underlying token. ([#2633](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2633))
  * Enumerables: Improve gas cost of removal in `EnumerableSet` and `EnumerableMap`.
  * Enumerables: Improve gas cost of lookup in `EnumerableSet` and `EnumerableMap`.
  * `Counter`: add a reset method. ([#2678](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2678))

+ 17 - 0
contracts/mocks/ERC20WrapperMock.sol

@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../token/ERC20/extensions/ERC20Wrapper.sol";
+
+contract ERC20WrapperMock is ERC20Wrapper {
+    constructor(
+        IERC20 _underlyingToken,
+        string memory name,
+        string memory symbol
+    ) ERC20(name, symbol) ERC20Wrapper(_underlyingToken) {}
+
+    function recover(address account) public returns (uint256) {
+        return _recover(account);
+    }
+}

+ 3 - 0
contracts/token/ERC20/README.adoc

@@ -23,6 +23,7 @@ Additionally there are multiple custom extensions, including:
 * {ERC20FlashMint}: token level support for flash loans through the minting and burning of ephemeral tokens (standardized as ERC3156).
 * {ERC20Votes}: support for voting and vote delegation.
 * {ERC20VotesComp}: support for voting and vote delegation (compatible with Compound's tokenn, with uint96 restrictions).
+* {ERC20Wrapper}: wrapper to create an ERC20 backed by another ERC20, with deposit and withdraw methods. Useful in conjunction with {ERC20Votes}.
 
 Finally, there are some utilities to interact with ERC20 contracts in various ways.
 
@@ -58,6 +59,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel
 
 {{ERC20VotesComp}}
 
+{{ERC20Wrapper}}
+
 == Draft EIPs
 
 The following EIPs are still in Draft status. Due to their nature as drafts, the details of these contracts may change and we cannot guarantee their xref:ROOT:releases-stability.adoc[stability]. Minor releases of OpenZeppelin Contracts may contain breaking changes for the contracts in this directory, which will be duly announced in the https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/CHANGELOG.md[changelog]. The EIPs included here are used by projects in production and this may make them less likely to change significantly.

+ 51 - 0
contracts/token/ERC20/extensions/ERC20Wrapper.sol

@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.0;
+
+import "../ERC20.sol";
+import "../utils/SafeERC20.sol";
+
+/**
+ * @dev Extension of the ERC20 token contract to support token wrapping.
+ *
+ * Users can deposit and withdraw "underlying tokens" and receive a matching number of "wrapped tokens". This is useful
+ * in conjunction with other modules. For example, combining this wrapping mechanism with {ERC20Votes} will allow the
+ * wrapping of an existing "basic" ERC20 into a governance token.
+ *
+ * _Available since v4.2._
+ */
+abstract contract ERC20Wrapper is ERC20 {
+    IERC20 public immutable underlying;
+
+    constructor(IERC20 underlyingToken) {
+        underlying = underlyingToken;
+    }
+
+    /**
+     * @dev Allow a user to deposit underlying tokens and mint the corresponding number of wrapped tokens.
+     */
+    function depositFor(address account, uint256 amount) public virtual returns (bool) {
+        SafeERC20.safeTransferFrom(underlying, _msgSender(), address(this), amount);
+        _mint(account, amount);
+        return true;
+    }
+
+    /**
+     * @dev Allow a user to burn a number of wrapped tokens and withdraw the corresponding number of underlying tokens.
+     */
+    function withdrawTo(address account, uint256 amount) public virtual returns (bool) {
+        _burn(_msgSender(), amount);
+        SafeERC20.safeTransfer(underlying, account, amount);
+        return true;
+    }
+
+    /**
+     * @dev Mint wrapped token to cover any underlyingTokens that would have been transfered by mistake. Internal
+     * function that can be exposed with access control if desired.
+     */
+    function _recover(address account) internal virtual returns (uint256) {
+        uint256 value = underlying.balanceOf(address(this)) - totalSupply();
+        _mint(account, value);
+        return value;
+    }
+}

+ 181 - 0
test/token/ERC20/extensions/ERC20Wrapper.test.js

@@ -0,0 +1,181 @@
+const { BN, constants, expectEvent, expectRevert } = require('@openzeppelin/test-helpers');
+const { expect } = require('chai');
+const { ZERO_ADDRESS, MAX_UINT256 } = constants;
+
+const { shouldBehaveLikeERC20 } = require('../ERC20.behavior');
+
+const ERC20Mock = artifacts.require('ERC20Mock');
+const ERC20WrapperMock = artifacts.require('ERC20WrapperMock');
+
+contract('ERC20', function (accounts) {
+  const [ initialHolder, recipient, anotherAccount ] = accounts;
+
+  const name = 'My Token';
+  const symbol = 'MTKN';
+
+  const initialSupply = new BN(100);
+
+  beforeEach(async function () {
+    this.underlying = await ERC20Mock.new(name, symbol, initialHolder, initialSupply);
+    this.token = await ERC20WrapperMock.new(this.underlying.address, `Wrapped ${name}`, `W${symbol}`);
+  });
+
+  afterEach(async function () {
+    expect(await this.underlying.balanceOf(this.token.address)).to.be.bignumber.equal(await this.token.totalSupply());
+  });
+
+  it('has a name', async function () {
+    expect(await this.token.name()).to.equal(`Wrapped ${name}`);
+  });
+
+  it('has a symbol', async function () {
+    expect(await this.token.symbol()).to.equal(`W${symbol}`);
+  });
+
+  it('has 18 decimals', async function () {
+    expect(await this.token.decimals()).to.be.bignumber.equal('18');
+  });
+
+  it('has underlying', async function () {
+    expect(await this.token.underlying()).to.be.bignumber.equal(this.underlying.address);
+  });
+
+  describe('deposit', function () {
+    it('valid', async function () {
+      await this.underlying.approve(this.token.address, initialSupply, { from: initialHolder });
+      const { tx } = await this.token.depositFor(initialHolder, initialSupply, { from: initialHolder });
+      expectEvent.inTransaction(tx, this.underlying, 'Transfer', {
+        from: initialHolder,
+        to: this.token.address,
+        value: initialSupply,
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: ZERO_ADDRESS,
+        to: initialHolder,
+        value: initialSupply,
+      });
+    });
+
+    it('missing approval', async function () {
+      await expectRevert(
+        this.token.depositFor(initialHolder, initialSupply, { from: initialHolder }),
+        'ERC20: transfer amount exceeds allowance',
+      );
+    });
+
+    it('missing balance', async function () {
+      await this.underlying.approve(this.token.address, MAX_UINT256, { from: initialHolder });
+      await expectRevert(
+        this.token.depositFor(initialHolder, MAX_UINT256, { from: initialHolder }),
+        'ERC20: transfer amount exceeds balance',
+      );
+    });
+
+    it('to other account', async function () {
+      await this.underlying.approve(this.token.address, initialSupply, { from: initialHolder });
+      const { tx } = await this.token.depositFor(anotherAccount, initialSupply, { from: initialHolder });
+      expectEvent.inTransaction(tx, this.underlying, 'Transfer', {
+        from: initialHolder,
+        to: this.token.address,
+        value: initialSupply,
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: ZERO_ADDRESS,
+        to: anotherAccount,
+        value: initialSupply,
+      });
+    });
+  });
+
+  describe('withdraw', function () {
+    beforeEach(async function () {
+      await this.underlying.approve(this.token.address, initialSupply, { from: initialHolder });
+      await this.token.depositFor(initialHolder, initialSupply, { from: initialHolder });
+    });
+
+    it('missing balance', async function () {
+      await expectRevert(
+        this.token.withdrawTo(initialHolder, MAX_UINT256, { from: initialHolder }),
+        'ERC20: burn amount exceeds balance',
+      );
+    });
+
+    it('valid', async function () {
+      const value = new BN(42);
+
+      const { tx } = await this.token.withdrawTo(initialHolder, value, { from: initialHolder });
+      expectEvent.inTransaction(tx, this.underlying, 'Transfer', {
+        from: this.token.address,
+        to: initialHolder,
+        value: value,
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: initialHolder,
+        to: ZERO_ADDRESS,
+        value: value,
+      });
+    });
+
+    it('entire balance', async function () {
+      const { tx } = await this.token.withdrawTo(initialHolder, initialSupply, { from: initialHolder });
+      expectEvent.inTransaction(tx, this.underlying, 'Transfer', {
+        from: this.token.address,
+        to: initialHolder,
+        value: initialSupply,
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: initialHolder,
+        to: ZERO_ADDRESS,
+        value: initialSupply,
+      });
+    });
+
+    it('to other account', async function () {
+      const { tx } = await this.token.withdrawTo(anotherAccount, initialSupply, { from: initialHolder });
+      expectEvent.inTransaction(tx, this.underlying, 'Transfer', {
+        from: this.token.address,
+        to: anotherAccount,
+        value: initialSupply,
+      });
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: initialHolder,
+        to: ZERO_ADDRESS,
+        value: initialSupply,
+      });
+    });
+  });
+
+  describe('recover', function () {
+    it('nothing to recover', async function () {
+      await this.underlying.approve(this.token.address, initialSupply, { from: initialHolder });
+      await this.token.depositFor(initialHolder, initialSupply, { from: initialHolder });
+
+      const { tx } = await this.token.recover(anotherAccount);
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: ZERO_ADDRESS,
+        to: anotherAccount,
+        value: '0',
+      });
+    });
+
+    it('something to recover', async function () {
+      await this.underlying.transfer(this.token.address, initialSupply, { from: initialHolder });
+
+      const { tx } = await this.token.recover(anotherAccount);
+      expectEvent.inTransaction(tx, this.token, 'Transfer', {
+        from: ZERO_ADDRESS,
+        to: anotherAccount,
+        value: initialSupply,
+      });
+    });
+  });
+
+  describe('erc20 behaviour', function () {
+    beforeEach(async function () {
+      await this.underlying.approve(this.token.address, initialSupply, { from: initialHolder });
+      await this.token.depositFor(initialHolder, initialSupply, { from: initialHolder });
+    });
+
+    shouldBehaveLikeERC20('ERC20', initialSupply, initialHolder, recipient, anotherAccount);
+  });
+});