diff --git a/contracts/interfaces/IBatcher.sol b/contracts/interfaces/IBatcher.sol index 696aed8..508433e 100644 --- a/contracts/interfaces/IBatcher.sol +++ b/contracts/interfaces/IBatcher.sol @@ -18,7 +18,7 @@ interface IBatcher { function RESERVE() external view returns (IEmptySetReserve); // solhint-disable-line func-name-mixedcase function USDC() external view returns (Token6); // solhint-disable-line func-name-mixedcase function DSU() external view returns (Token18); // solhint-disable-line func-name-mixedcase - function totalBalance() external view returns (UFixed18); +function totalBalance() external view returns (UFixed18); function wrap(UFixed18 amount, address to) external; function unwrap(UFixed18 amount, address to) external; function rebalance() external; diff --git a/contracts/interfaces/IWrapper.sol b/contracts/interfaces/IWrapper.sol new file mode 100644 index 0000000..74891d9 --- /dev/null +++ b/contracts/interfaces/IWrapper.sol @@ -0,0 +1,13 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.17; + +import "@equilibria/root/token/types/Token6.sol"; +import "@equilibria/root/token/types/Token18.sol"; + +interface IWrapper { + function USDC() external view returns (Token6); // solhint-disable-line func-name-mixedcase + function DSU() external view returns (Token18); // solhint-disable-line func-name-mixedcase + + function wrap(address to) external; + function unwrap(address to) external; +} diff --git a/contracts/mock/MockEmptySetReserve.sol b/contracts/mock/MockEmptySetReserve.sol new file mode 100644 index 0000000..050d8d2 --- /dev/null +++ b/contracts/mock/MockEmptySetReserve.sol @@ -0,0 +1,41 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.17; + +import "@equilibria/root/token/types/Token18.sol"; +import "@equilibria/root/token/types/Token6.sol"; +import "../interfaces/IEmptySetReserve.sol"; + +/// @title MockEmptySetReserve +/// @notice Mock contract that allows the user to simulate the EmptySetReserve having partial solvency. +contract MockEmptySetReserve is IEmptySetReserve { + /// @dev DSU address + Token18 public immutable DSU; // solhint-disable-line var-name-mixedcase + + /// @dev USDC address + Token6 public immutable USDC; // solhint-disable-line var-name-mixedcase + + UFixed18 public immutable mintRatio; + UFixed18 public immutable redeemRatio; + + constructor(Token18 dsu_, Token6 usdc_, UFixed18 mintRatio_, UFixed18 redeemRatio_) { + DSU = dsu_; + USDC = usdc_; + + mintRatio = mintRatio_; + redeemRatio = redeemRatio_; + } + + function debt(address) external pure returns (UFixed18) { + return UFixed18Lib.ZERO; + } + + function repay(address borrower, UFixed18 amount) external {} + + function mint(UFixed18 amount) external { + DSU.push(msg.sender, amount.mul(mintRatio)); + } + + function redeem(UFixed18 amount) external { + USDC.push(msg.sender, amount.mul(redeemRatio)); + } +} diff --git a/contracts/mock/MockTwoWayBatcher.sol b/contracts/mock/MockTwoWayBatcher.sol new file mode 100644 index 0000000..fe01ff2 --- /dev/null +++ b/contracts/mock/MockTwoWayBatcher.sol @@ -0,0 +1,26 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.17; + +import "../batcher/TwoWayBatcher.sol"; + +contract MockTwoWayBatcher is TwoWayBatcher { + UFixed18 public immutable wrapRatio; + UFixed18 public immutable unwrapRatio; + + constructor(IEmptySetReserve reserve_, Token18 dsu_, Token6 usdc_, UFixed18 wrapRatio_, UFixed18 unwrapRatio_) + TwoWayBatcher(reserve_, dsu_, usdc_) + { + wrapRatio = wrapRatio_; + unwrapRatio = unwrapRatio_; + } + + function _wrap(UFixed18 amount, address to) override internal { + USDC.pull(msg.sender, amount, true); + DSU.push(to, amount.mul(wrapRatio)); + } + + function _unwrap(UFixed18 amount, address to) override internal { + DSU.pull(msg.sender, amount); + USDC.push(to, amount.mul(unwrapRatio)); + } +} diff --git a/contracts/wrapper/Wrapper.sol b/contracts/wrapper/Wrapper.sol new file mode 100644 index 0000000..09337cc --- /dev/null +++ b/contracts/wrapper/Wrapper.sol @@ -0,0 +1,85 @@ +//SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.17; + +import "@equilibria/root/token/types/Token18.sol"; +import "@equilibria/root/token/types/Token6.sol"; +import "@equilibria/root/control/unstructured/UReentrancyGuard.sol"; +import "@equilibria/root/control/unstructured/UOwnable.sol"; +import "../interfaces/ITwoWayBatcher.sol"; +import "../interfaces/IWrapper.sol"; + +/// @title Wrapper +/// @notice Helper contract for wrapping and unwrapping USDC and DSU without worrying about approvals or dependences. +/// @dev To wrap USDC, the caller must first send USDC to this contract, then call `wrap`. +/// Similarly, to unwrap DSU, the caller must first send DSU to this contract, then call +/// `unwrap`. This contract can be optionally supplied with a TwoWayBatcher to save gas. It is +/// recommended to only use this contract in contexts where the caller can atomically bundle +/// the USDC/DSU transfer with the `wrap`/`unwrap` call (e.g. if the caller is a contract that +/// can bundle the transfer with the call in a single transaction, or if the caller is an EOA +/// using flashbots-style bundles). +contract Wrapper is IWrapper, UReentrancyGuard, UOwnable { + /// @dev Reserve address + IEmptySetReserve public immutable RESERVE; // solhint-disable-line var-name-mixedcase + + /// @dev DSU address + Token18 public immutable DSU; // solhint-disable-line var-name-mixedcase + + /// @dev USDC address + Token6 public immutable USDC; // solhint-disable-line var-name-mixedcase + + /// @dev Batcher address + ITwoWayBatcher public batcher; + + /// @notice Initializes the Wrapper + /// @param reserve_ EmptySet Reserve address + /// @param batcher_ Optional TwoWayBatcher address (can be set to address(0) if doesn't exist) + /// @param dsu_ DSU Token address + /// @param usdc_ USDC Token Address + constructor(IEmptySetReserve reserve_, ITwoWayBatcher batcher_, Token18 dsu_, Token6 usdc_) { + __UReentrancyGuard__initialize(); + __UOwnable__initialize(); + + DSU = dsu_; + USDC = usdc_; + RESERVE = reserve_; + DSU.approve(address(RESERVE)); + USDC.approve(address(RESERVE)); + if (address(batcher_) != address(0)) setBatcher(batcher_); + } + + /// @notice Updates the Batcher address + /// @param batcher_ TwoWayBatcher address + function setBatcher(ITwoWayBatcher batcher_) public onlyOwner { + batcher = batcher_; + DSU.approve(address(batcher)); + USDC.approve(address(batcher)); + } + + /// @notice Wraps all USDC owned by this contract and sends the DSU to `to` + /// @dev Falls back on the non-batcher wrapping flow if no batcher is set or the batcher has + /// too little DSU. + /// @param to Receiving address of resulting DSU + function wrap(address to) external nonReentrant { + UFixed18 usdcBalance = USDC.balanceOf(); + if (address(batcher) != address(0) && DSU.balanceOf(address(batcher)).gte(usdcBalance)) { + batcher.wrap(usdcBalance, address(this)); + } else { + RESERVE.mint(usdcBalance); + } + DSU.push(to, usdcBalance); + } + + /// @notice Unwraps all DSU owned by this contract and sends the USDC to `to` + /// @dev Falls back on the non-batcher wrapping flow if no batcher is set or the batcher has + /// too little USDC. + /// @param to Receiving address of resulting USDC + function unwrap(address to) external nonReentrant { + UFixed18 dsuBalance = DSU.balanceOf(); + if (address(batcher) != address(0) && USDC.balanceOf(address(batcher)).gte(dsuBalance)) { + batcher.unwrap(dsuBalance, address(this)); + } else { + RESERVE.redeem(dsuBalance); + } + USDC.push(to, dsuBalance); + } +} diff --git a/package.json b/package.json index f2f1b98..8ed01c7 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@ethersproject/bytes": "^5.0.0", "@ethersproject/providers": "^5.3.1", "@nomicfoundation/hardhat-chai-matchers": "^1.0.4", + "@nomicfoundation/hardhat-network-helpers": "^1.0.9", "@nomiclabs/hardhat-ethers": "^2.2.1", "@nomiclabs/hardhat-etherscan": "^2.1.1", "@openzeppelin/contracts": "4.6.0", diff --git a/test/integration/wrapper/WrapperIntegration.test.ts b/test/integration/wrapper/WrapperIntegration.test.ts new file mode 100644 index 0000000..a841477 --- /dev/null +++ b/test/integration/wrapper/WrapperIntegration.test.ts @@ -0,0 +1,225 @@ +import { reset } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import HRE from 'hardhat' +import { utils, constants, BigNumber } from 'ethers' + +import { + IERC20Metadata, + IERC20Metadata__factory, + MockEmptySetReserve, + MockEmptySetReserve__factory, + MockTwoWayBatcher, + MockTwoWayBatcher__factory, + TwoWayBatcher, + TwoWayBatcher__factory, + Wrapper, + Wrapper__factory, +} from '../../../types/generated' +import { impersonateWithBalance } from '../../testutil/impersonate' +import { getContracts } from '../constant' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { parseEther, parseUnits } from 'ethers/lib/utils' + +const { ethers } = HRE + +const BATCHER_ADDRESS = '0xAEf566ca7E84d1E736f999765a804687f39D9094' +const RESERVE_ADDRESS = '0xD05aCe63789cCb35B9cE71d01e4d632a0486Da4B' + +describe('Wrapper', () => { + let dsu: IERC20Metadata + let usdc: IERC20Metadata + let owner: SignerWithAddress + let user: SignerWithAddress + let batcher: TwoWayBatcher + let wrapper: Wrapper + let wrapperNoBatcher: Wrapper + let wrapperInsolvent: Wrapper + let reserveInsolvent: MockEmptySetReserve + let batcherInsolvent: MockTwoWayBatcher + + beforeEach(async () => { + await reset(process.env.MAINNET_NODE_URL || '', 18333333) + ;[owner, user] = await ethers.getSigners() + const contracts = getContracts('mainnet')! + dsu = IERC20Metadata__factory.connect(contracts.DSU, owner) + usdc = IERC20Metadata__factory.connect(contracts.USDC, owner) + + batcher = TwoWayBatcher__factory.connect(BATCHER_ADDRESS, owner) + wrapper = await new Wrapper__factory(owner).deploy(RESERVE_ADDRESS, BATCHER_ADDRESS, dsu.address, usdc.address) + wrapperNoBatcher = await new Wrapper__factory(owner).deploy( + RESERVE_ADDRESS, + constants.AddressZero, + dsu.address, + usdc.address, + ) + + reserveInsolvent = await new MockEmptySetReserve__factory(owner).deploy( + dsu.address, + usdc.address, + parseEther('0.9'), + parseEther('0.9'), + ) + wrapperInsolvent = await new Wrapper__factory(owner).deploy( + reserveInsolvent.address, + constants.AddressZero, + dsu.address, + usdc.address, + ) + batcherInsolvent = await new MockTwoWayBatcher__factory(owner).deploy( + RESERVE_ADDRESS, + dsu.address, + usdc.address, + parseEther('0.9'), + parseEther('0.9'), + ) + }) + + describe('#constructor', async () => { + it('sets fields', async () => { + expect(await wrapper.RESERVE()).to.equal(RESERVE_ADDRESS) + expect(await wrapper.batcher()).to.equal(BATCHER_ADDRESS) + expect(await wrapper.DSU()).to.equal(dsu.address) + expect(await wrapper.USDC()).to.equal(usdc.address) + + expect(await wrapperNoBatcher.RESERVE()).to.equal(RESERVE_ADDRESS) + expect(await wrapperNoBatcher.batcher()).to.equal(constants.AddressZero) + expect(await wrapperNoBatcher.DSU()).to.equal(dsu.address) + expect(await wrapperNoBatcher.USDC()).to.equal(usdc.address) + }) + + it('approves batcher', async () => { + expect(await dsu.allowance(wrapper.address, BATCHER_ADDRESS)).to.equal(ethers.constants.MaxUint256) + expect(await usdc.allowance(wrapper.address, BATCHER_ADDRESS)).to.equal(ethers.constants.MaxUint256) + }) + }) + + describe('#setBatcher', async () => { + let newBatcher: TwoWayBatcher + + beforeEach(async () => { + newBatcher = await new TwoWayBatcher__factory(owner).deploy(RESERVE_ADDRESS, dsu.address, usdc.address) + }) + + it('sets batcher', async () => { + await wrapper.setBatcher(newBatcher.address) + await wrapperNoBatcher.setBatcher(newBatcher.address) + expect(await wrapper.batcher()).to.equal(newBatcher.address) + expect(await wrapperNoBatcher.batcher()).to.equal(newBatcher.address) + }) + + it('blocks non-owners from setting batcher', async () => { + await expect(wrapper.connect(user).setBatcher(newBatcher.address)).to.be.revertedWithCustomError( + wrapper, + 'UOwnableNotOwnerError', + ) + await expect(wrapperNoBatcher.connect(user).setBatcher(newBatcher.address)).to.be.revertedWithCustomError( + wrapperNoBatcher, + 'UOwnableNotOwnerError', + ) + }) + }) + + describe('#wrap', async () => { + const usdcBalance = '1000' + const highUsdcBalance = '2000000' + let usdcHolder: SignerWithAddress + let originalDsuBalance: BigNumber + + beforeEach(async () => { + usdcHolder = await impersonateWithBalance(getContracts('mainnet')!.USDC_HOLDER, utils.parseEther('10')) + await usdc.connect(usdcHolder).transfer(user.address, parseUnits(usdcBalance, 6)) + originalDsuBalance = await dsu.balanceOf(user.address) + }) + + it('wraps with batcher', async () => { + await usdc.connect(user).transfer(wrapper.address, parseUnits(usdcBalance, 6)) + await expect(wrapper.connect(user).wrap(user.address)) + .to.emit(batcher, 'Wrap') + .withArgs(wrapper.address, parseEther(usdcBalance)) + expect((await dsu.balanceOf(user.address)).sub(originalDsuBalance)).to.equal(parseEther(usdcBalance)) + }) + + it('wraps without batcher (no batcher)', async () => { + await usdc.connect(user).transfer(wrapperNoBatcher.address, parseUnits(usdcBalance, 6)) + await expect(wrapperNoBatcher.connect(user).wrap(user.address)).to.not.emit(batcher, 'Wrap') + expect((await dsu.balanceOf(user.address)).sub(originalDsuBalance)).to.equal(parseEther(usdcBalance)) + }) + + it('wraps without batcher (fall back)', async () => { + await usdc.connect(usdcHolder).transfer(user.address, parseUnits(highUsdcBalance, 6)) + await usdc.connect(user).transfer(wrapper.address, parseUnits(highUsdcBalance, 6)) + await expect(wrapper.connect(user).wrap(user.address)).to.not.emit(batcher, 'Wrap') + expect((await dsu.balanceOf(user.address)).sub(originalDsuBalance)).to.equal(parseEther(highUsdcBalance)) + }) + + it('does not wrap if batcher has partial solvency', async () => { + await wrapperInsolvent.setBatcher(batcherInsolvent.address) + + await usdc.connect(usdcHolder).transfer(wrapperNoBatcher.address, parseUnits('1000', 6)) + await wrapperNoBatcher.wrap(batcherInsolvent.address) + + await usdc.connect(user).transfer(wrapperInsolvent.address, parseUnits('10', 6)) + await expect(wrapperInsolvent.connect(user).wrap(user.address)).to.reverted + }) + + it('does not wrap if reserve has partial solvency', async () => { + await usdc.connect(usdcHolder).transfer(wrapperNoBatcher.address, parseUnits('1000', 6)) + await wrapperNoBatcher.wrap(reserveInsolvent.address) + + await usdc.connect(user).transfer(wrapperInsolvent.address, parseUnits('10', 6)) + await expect(wrapperInsolvent.connect(user).wrap(user.address)).to.reverted + }) + }) + + describe('#unwrap', async () => { + const dsuBalance = '1000' + const highDsuBalance = '2000000' + let usdcHolder: SignerWithAddress + let originalUsdcBalance: BigNumber + + beforeEach(async () => { + usdcHolder = await impersonateWithBalance(getContracts('mainnet')!.USDC_HOLDER, utils.parseEther('10')) + await usdc.connect(usdcHolder).transfer(wrapperNoBatcher.address, parseUnits(dsuBalance, 6)) + await wrapperNoBatcher.wrap(user.address) + originalUsdcBalance = await usdc.balanceOf(user.address) + }) + + it('unwraps with batcher', async () => { + await dsu.connect(user).transfer(wrapper.address, parseEther(dsuBalance)) + await expect(wrapper.connect(user).unwrap(user.address)) + .to.emit(batcher, 'Unwrap') + .withArgs(wrapper.address, parseEther(dsuBalance)) + expect((await usdc.balanceOf(user.address)).sub(originalUsdcBalance)).to.equal(parseUnits(dsuBalance, 6)) + }) + + it('unwraps without batcher (no batcher)', async () => { + await dsu.connect(user).transfer(wrapperNoBatcher.address, parseEther(dsuBalance)) + await expect(wrapperNoBatcher.connect(user).unwrap(user.address)).to.not.emit(batcher, 'Unwrap') + expect((await usdc.balanceOf(user.address)).sub(originalUsdcBalance)).to.equal(parseUnits(dsuBalance, 6)) + }) + + it('unwraps without batcher (fall back)', async () => { + await usdc.connect(usdcHolder).transfer(wrapperNoBatcher.address, parseUnits(highDsuBalance, 6)) + await wrapperNoBatcher.wrap(user.address) + await dsu.connect(user).transfer(wrapperNoBatcher.address, parseEther(highDsuBalance)) + await expect(wrapperNoBatcher.connect(user).unwrap(user.address)).to.not.emit(batcher, 'Unwrap') + expect((await usdc.balanceOf(user.address)).sub(originalUsdcBalance)).to.equal(parseUnits(highDsuBalance, 6)) + }) + + it('does not unwrap if batcher has partial solvency', async () => { + await wrapperInsolvent.setBatcher(batcherInsolvent.address) + + await usdc.connect(usdcHolder).transfer(batcherInsolvent.address, parseUnits('1000', 6)) + + await dsu.connect(user).transfer(wrapperInsolvent.address, parseEther('10')) + await expect(wrapperInsolvent.connect(user).unwrap(user.address)).to.reverted + }) + + it('does not unwrap if reserve has partial solvency', async () => { + await usdc.connect(usdcHolder).transfer(reserveInsolvent.address, parseUnits('1000', 6)) + + await dsu.connect(user).transfer(wrapperInsolvent.address, parseEther('10')) + await expect(wrapperInsolvent.connect(user).unwrap(user.address)).to.reverted + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index e4f91d0..216d840 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1196,6 +1196,13 @@ deep-eql "^4.0.1" ordinal "^1.0.3" +"@nomicfoundation/hardhat-network-helpers@^1.0.9": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@nomicfoundation/hardhat-network-helpers/-/hardhat-network-helpers-1.0.9.tgz#767449e8a2acda79306ac84626117583d95d25aa" + integrity sha512-OXWCv0cHpwLUO2u7bFxBna6dQtCC2Gg/aN/KtJLO7gmuuA28vgmVKYFRCDUqrbjujzgfwQ2aKyZ9Y3vSmDqS7Q== + dependencies: + ethereumjs-util "^7.1.4" + "@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@nomicfoundation/solidity-analyzer-darwin-arm64/-/solidity-analyzer-darwin-arm64-0.1.0.tgz#83a7367342bd053a76d04bbcf4f373fef07cf760" @@ -3127,6 +3134,17 @@ ethereumjs-util@^7.1.0: ethjs-util "0.1.6" rlp "^2.2.4" +ethereumjs-util@^7.1.4: + version "7.1.5" + resolved "https://registry.yarnpkg.com/ethereumjs-util/-/ethereumjs-util-7.1.5.tgz#9ecf04861e4fbbeed7465ece5f23317ad1129181" + integrity sha512-SDl5kKrQAudFBUe5OJM9Ac6WmMyYmXX/6sTmLZ3ffG2eY6ZIGBes3pEDxNN6V72WyOw4CPD5RomKdsa8DAAwLg== + dependencies: + "@types/bn.js" "^5.1.0" + bn.js "^5.1.2" + create-hash "^1.1.2" + ethereum-cryptography "^0.1.3" + rlp "^2.2.4" + ethers@^4.0.40: version "4.0.49" resolved "https://registry.yarnpkg.com/ethers/-/ethers-4.0.49.tgz#0eb0e9161a0c8b4761be547396bbe2fb121a8894"