From 6842518b1b71fac9a21c7d94ec521992cff266b5 Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Wed, 23 Jun 2021 00:27:33 +0200 Subject: [PATCH] Wrapper extension for ERC20 token (#2633) Co-authored-by: Francisco Giordano --- CHANGELOG.md | 1 + contracts/mocks/ERC20WrapperMock.sol | 17 ++ contracts/token/ERC20/README.adoc | 3 + .../token/ERC20/extensions/ERC20Wrapper.sol | 51 +++++ .../ERC20/extensions/ERC20Wrapper.test.js | 181 ++++++++++++++++++ 5 files changed, 253 insertions(+) create mode 100644 contracts/mocks/ERC20WrapperMock.sol create mode 100644 contracts/token/ERC20/extensions/ERC20Wrapper.sol create mode 100644 test/token/ERC20/extensions/ERC20Wrapper.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a6cd96da1..dd7cc3f0b 100644 --- a/CHANGELOG.md +++ b/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)) diff --git a/contracts/mocks/ERC20WrapperMock.sol b/contracts/mocks/ERC20WrapperMock.sol new file mode 100644 index 000000000..cf34a7a52 --- /dev/null +++ b/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); + } +} diff --git a/contracts/token/ERC20/README.adoc b/contracts/token/ERC20/README.adoc index a1d3df6a3..353ca0daf 100644 --- a/contracts/token/ERC20/README.adoc +++ b/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. diff --git a/contracts/token/ERC20/extensions/ERC20Wrapper.sol b/contracts/token/ERC20/extensions/ERC20Wrapper.sol new file mode 100644 index 000000000..64dd99271 --- /dev/null +++ b/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; + } +} diff --git a/test/token/ERC20/extensions/ERC20Wrapper.test.js b/test/token/ERC20/extensions/ERC20Wrapper.test.js new file mode 100644 index 000000000..5b0edc0b7 --- /dev/null +++ b/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); + }); +});