diff --git a/contracts/Tranche.sol b/contracts/Tranche.sol index 7517a288..82b265bc 100644 --- a/contracts/Tranche.sol +++ b/contracts/Tranche.sol @@ -15,7 +15,7 @@ import "./libraries/DateString.sol"; contract Tranche is ERC20Permit, ITranche { IInterestToken public immutable override interestToken; IWrappedPosition public immutable position; - IERC20 public immutable underlying; + IERC20 public immutable override underlying; uint8 internal immutable _underlyingDecimals; // The outstanding amount of underlying which @@ -25,7 +25,7 @@ contract Tranche is ERC20Permit, ITranche { // The total supply of interest tokens uint128 public override interestSupply; // The timestamp when tokens can be redeemed. - uint256 public immutable unlockTimestamp; + uint256 public immutable override unlockTimestamp; // The amount of slippage allowed on the Principal token redemption [0.1 basis points] uint256 internal constant _SLIPPAGE_BP = 1e13; // The speedbump variable records the first timestamp where redemption was attempted to be diff --git a/contracts/external/nexus/WrappedCoveredPrincipalToken.sol b/contracts/external/nexus/WrappedCoveredPrincipalToken.sol new file mode 100644 index 00000000..8572dc66 --- /dev/null +++ b/contracts/external/nexus/WrappedCoveredPrincipalToken.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.0; + +import { ERC20PermitWithSupply, ERC20Permit, IERC20Permit } from "../../libraries/ERC20PermitWithSupply.sol"; +import { IWrappedPosition } from "../../interfaces/IWrappedPosition.sol"; +import { ITranche } from "../../interfaces/ITranche.sol"; +import { IWrappedCoveredPrincipalToken } from "./interfaces/IWrappedCoveredPrincipalToken.sol"; +import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol"; + +/// @author Element Finance +/// @title WrappedCoveredPrincipalToken +contract WrappedCoveredPrincipalToken is + ERC20PermitWithSupply, + AccessControl, + IWrappedCoveredPrincipalToken +{ + using EnumerableSet for EnumerableSet.AddressSet; + using SafeERC20 for IERC20; + + // Address of the base/underlying token which is used to buy the yield bearing token from the wrapped position. + // Ex - Dai is used to buy the yvDai yield bearing token + address public immutable override baseToken; + + // Enumerable address list, It contains the list of allowed wrapped positions that are covered by this contract + // Criteria to choose the wrapped position are - + // a). Wrapped position should have same underlying/base token (i.e ETH, BTC, USDC). + // b). Should have the similar risk profiles. + EnumerableSet.AddressSet private _allowedWrappedPositions; + + // Tranche factory address for Tranche contract address derivation + address internal immutable _trancheFactory; + // Tranche bytecode hash for Tranche contract address derivation. + // This is constant as long as Tranche does not implement non-constant constructor arguments. + bytes32 internal immutable _trancheBytecodeHash; + + // Role identifier that can use to do some operational stuff. + bytes32 public constant ADMIN_ROLE = bytes32("ADMIN_ROLE"); + + // Role identifier that allow a particular account to reap principal tokens out of the contract. + bytes32 public constant RECLAIM_ROLE = bytes32("RECLAIM_ROLE"); + + // Emitted when new wrapped position get whitelisted. + event WrappedPositionAdded(address _wrappedPosition); + + // Emitted when the principal tokens get reclaimed. + event Reclaimed(address _tranche, uint256 _amount); + + /// @notice Modifier to validate the wrapped position is whitelisted or not. + modifier isValidWp(address _wrappedPosition) { + require(!isAllowedWp(_wrappedPosition), "WFP:ALREADY_EXISTS"); + _; + } + + ///@notice Initialize the wrapped token. + ///@dev Wrapped token have 18 decimals, It is independent of the baseToken decimals. + constructor( + address _baseToken, + address _owner, + address __trancheFactory, + bytes32 __trancheBytecodeHash + ) + ERC20Permit( + _processName(IERC20Metadata(_baseToken).symbol()), + _processSymbol(IERC20Metadata(_baseToken).symbol()) + ) + { + baseToken = _baseToken; + _trancheFactory = __trancheFactory; + _trancheBytecodeHash = __trancheBytecodeHash; + _setupRole(ADMIN_ROLE, _owner); + _setRoleAdmin(ADMIN_ROLE, ADMIN_ROLE); + _setRoleAdmin(RECLAIM_ROLE, ADMIN_ROLE); + } + + ///@notice Allows to create the name for the wrapped token. + function _processName(string memory _tokenSymbol) + internal + pure + returns (string memory) + { + return + string( + abi.encodePacked("Wrapped", _tokenSymbol, "Covered Principal") + ); + } + + ///@notice Allows to create the symbol for the wrapped token. + function _processSymbol(string memory _tokenSymbol) + internal + pure + returns (string memory) + { + return string(abi.encodePacked("W", _tokenSymbol)); + } + + /// @notice Add wrapped position within the allowed wrapped position enumerable set. + /// @dev It is only allowed to execute by the owner of the contract. + /// wrapped position which has underlying token equals to the base token are + /// only allowed to add, Otherwise it will revert. + /// @param _wrappedPosition Address of the Wrapped position which needs to add. + function addWrappedPosition(address _wrappedPosition) + external + override + isValidWp(_wrappedPosition) + onlyRole(ADMIN_ROLE) + { + require( + address(IWrappedPosition(_wrappedPosition).token()) == baseToken, + "WFP:INVALID_WP" + ); + _allowedWrappedPositions.add(_wrappedPosition); + emit WrappedPositionAdded(_wrappedPosition); + } + + /// @notice Allows the defaulter to mint wrapped tokens (Covered position) by + /// sending the de-pegged token to the contract. + /// @dev a) Only allow minting the covered position when the derived tranche got expired otherwise revert. + /// b) Sufficient allowance of the principal token (i.e tranche) should be provided + /// to the contract by the `msg.sender` to make execution successful. + /// @param _amount Amount of covered position / wrapped token `msg.sender` wants to mint. + /// @param _expiration Timestamp at which the derived tranche would get expired. + /// @param _wrappedPosition Address of the Wrapped position which is used to derive the tranche. + function mint( + uint256 _amount, + uint256 _expiration, + address _wrappedPosition, + PermitData calldata _permitCallData + ) external override { + require(isAllowedWp(_wrappedPosition), "WFP:INVALID_WP"); + address _tranche = address( + _deriveTranche(_wrappedPosition, _expiration) + ); + _usePermitData(_tranche, _permitCallData); + // Only allow minting when the position get expired. + require(_expiration < block.timestamp, "WFP:POSITION_NOT_EXPIRED"); + // Assumed that msg.sender provides the sufficient approval the contract. + IERC20(_tranche).safeTransferFrom( + msg.sender, + address(this), + _fromWad(_amount, _tranche) + ); + // Mint the corresponding wrapped token to the `msg.sender`. + _mint(msg.sender, _amount); + } + + /// @notice Tell whether the given `_wrappedPosition` is whitelisted or not. + /// @param _wrappedPosition Address of the wrapped position. + /// @return returns boolean, True -> allowed otherwise false. + function isAllowedWp(address _wrappedPosition) + public + view + override + returns (bool) + { + return _allowedWrappedPositions.contains(_wrappedPosition); + } + + /// @notice Returns the list of wrapped positions that are whitelisted with the contract. + /// Order is not maintained. + /// @return Array of addresses. + function allWrappedPositions() + external + view + override + returns (address[] memory) + { + return _allowedWrappedPositions.values(); + } + + /// @notice Reclaim tranche token (i.e principal token) by the authorized account. + /// @dev Only be called by the address which has the `RECLAIM_ROLE`, Should be Nexus Treasury. + /// @param _expiration Timestamp at which the derived tranche would get expired. + /// @param _wrappedPosition Address of the Wrapped position which is used to derive the tranche. + /// @param _to Address whom funds gets transferred. + function reclaimPt( + uint256 _expiration, + address _wrappedPosition, + address _to + ) external override onlyRole(RECLAIM_ROLE) { + require(isAllowedWp(_wrappedPosition), "WFP:INVALID_WP"); + address _tranche = address( + _deriveTranche(_wrappedPosition, _expiration) + ); + uint256 amount = IERC20(_tranche).balanceOf(address(this)); + IERC20(_tranche).safeTransfer(_to, amount); + emit Reclaimed(_tranche, amount); + } + + function _usePermitData(address _tranche, PermitData memory _d) internal { + if (_d.spender != address(0)) { + IERC20Permit(_tranche).permit( + msg.sender, + _d.spender, + _d.value, + _d.deadline, + _d.v, + _d.r, + _d.s + ); + } + } + + /// @notice Converts the decimal precision of given `_amount` to `_tranche` decimal. + function _fromWad(uint256 _amount, address _tranche) + internal + view + returns (uint256) + { + return (_amount * 10**IERC20Metadata(_tranche).decimals()) / 1e18; + } + + /// @dev This internal function produces the deterministic create2 + /// address of the Tranche contract from a wrapped position contract and expiration + /// @param _position The wrapped position contract address + /// @param _expiration The expiration time of the tranche + /// @return The derived Tranche contract + function _deriveTranche(address _position, uint256 _expiration) + internal + view + returns (ITranche) + { + bytes32 salt = keccak256(abi.encodePacked(_position, _expiration)); + bytes32 addressBytes = keccak256( + abi.encodePacked( + bytes1(0xff), + _trancheFactory, + salt, + _trancheBytecodeHash + ) + ); + return ITranche(address(uint160(uint256(addressBytes)))); + } +} diff --git a/contracts/external/nexus/WrappedCoveredPrincipalTokenFactory.sol b/contracts/external/nexus/WrappedCoveredPrincipalTokenFactory.sol new file mode 100644 index 00000000..81e9e2b6 --- /dev/null +++ b/contracts/external/nexus/WrappedCoveredPrincipalTokenFactory.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.0; + +import { WrappedCoveredPrincipalToken, EnumerableSet } from "./WrappedCoveredPrincipalToken.sol"; + +/// @author Element Finance +/// @title WrappedCoveredPrincipalTokenFactory +contract WrappedCoveredPrincipalTokenFactory { + using EnumerableSet for EnumerableSet.AddressSet; + + // Enumerable list of wrapped tokens that get created from the factory. + EnumerableSet.AddressSet private _WrappedCoveredPrincipalTokens; + + // Tranche factory address for Tranche contract address derivation + address internal immutable _trancheFactory; + // Tranche bytecode hash for Tranche contract address derivation. + // This is constant as long as Tranche does not implement non-constant constructor arguments. + bytes32 internal immutable _trancheBytecodeHash; + + // Emitted when new wrapped principal token get created. + event WrappedCoveredPrincipalTokenCreated( + address indexed _baseToken, + address indexed _owner + ); + + /// @notice Initializing the owner of the contract. + constructor(address __trancheFactory, bytes32 __trancheBytecodeHash) { + _trancheFactory = __trancheFactory; + _trancheBytecodeHash = __trancheBytecodeHash; + } + + /// @notice Allow the owner to create the new wrapped token. + /// @param _baseToken Address of the base token / underlying token that is used to buy the wrapped positions. + /// @param _owner Address of the owner of wrapped futures. + /// @return address of wrapped futures token. + function create(address _baseToken, address _owner) + external + returns (address) + { + // Validate the given params + _zeroAddressCheck(_owner); + _zeroAddressCheck(_baseToken); + address wcPrincipal = address( + new WrappedCoveredPrincipalToken( + _baseToken, + _owner, + _trancheFactory, + _trancheBytecodeHash + ) + ); + _WrappedCoveredPrincipalTokens.add(wcPrincipal); + emit WrappedCoveredPrincipalTokenCreated(_baseToken, _owner); + return wcPrincipal; + } + + /// @notice Returns the list of wrapped tokens that are whitelisted with the contract. + /// Order is not maintained. + /// @return Array of addresses. + function allWrappedCoveredPrincipalTokens() + public + view + returns (address[] memory) + { + return _WrappedCoveredPrincipalTokens.values(); + } + + /// @notice Sanity check for the zero address check. + function _zeroAddressCheck(address _target) internal pure { + require(_target != address(0), "WFPF:ZERO_ADDRESS"); + } +} diff --git a/contracts/external/nexus/interfaces/IWrappedCoveredPrincipalToken.sol b/contracts/external/nexus/interfaces/IWrappedCoveredPrincipalToken.sol new file mode 100644 index 00000000..d88c9586 --- /dev/null +++ b/contracts/external/nexus/interfaces/IWrappedCoveredPrincipalToken.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.0; + +import { IERC20Permit } from "../../../interfaces/IERC20Permit.sol"; + +interface IWrappedCoveredPrincipalToken is IERC20Permit { + // Memory encoding of the permit data + struct PermitData { + address spender; + uint256 value; + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; + } + + // Address of the base/underlying token which is used to buy the yield bearing token from the wrapped position. + // Ex - Dai is used to buy the yvDai yield bearing token. + function baseToken() external view returns (address); + + /// @notice Add wrapped position within the allowed wrapped position enumerable set. + /// @dev It is only allowed to execute by the owner of the contract. + /// wrapped position which has underlying token equals to the base token are + /// only allowed to add, Otherwise it will revert. + /// @param _wrappedPosition Address of the Wrapped position which needs to add. + function addWrappedPosition(address _wrappedPosition) external; + + /// @notice Allows the defaulter to mint wrapped tokens (Covered position) by + /// sending the de-pegged token to the contract. + /// @dev a) Only allow minting the covered position when the derived tranche got expired otherwise revert. + /// b) Sufficient allowance of the principal token (i.e tranche) should be provided + /// to the contract by the `msg.sender` to make execution successful. + /// @param _amount Amount of covered position / wrapped token `msg.sender` wants to mint. + /// @param _expiration Timestamp at which the derived tranche would get expired. + /// @param _wrappedPosition Address of the Wrapped position which is used to derive the tranche. + function mint( + uint256 _amount, + uint256 _expiration, + address _wrappedPosition, + PermitData calldata _permitCallData + ) external; + + /// @notice Tell whether the given `_wrappedPosition` is whitelisted or not. + /// @param _wrappedPosition Address of the wrapped position. + /// @return returns boolean, True -> allowed otherwise false. + function isAllowedWp(address _wrappedPosition) external view returns (bool); + + /// @notice Returns the list of wrapped positions that are whitelisted with the contract. + /// Order is not maintained. + /// @return Array of addresses. + function allWrappedPositions() external view returns (address[] memory); + + /// @notice Reclaim tranche token (i.e principal token) by the authorized account. + /// @dev Only be called by the address which has the `RECLAIM_ROLE`, Should be Nexus Treasury. + /// @param _expiration Timestamp at which the derived tranche would get expired. + /// @param _wrappedPosition Address of the Wrapped position which is used to derive the tranche. + /// @param _to Address whom funds gets transferred. + function reclaimPt( + uint256 _expiration, + address _wrappedPosition, + address _to + ) external; +} diff --git a/contracts/interfaces/ITranche.sol b/contracts/interfaces/ITranche.sol index 364ee5a7..8aff806a 100644 --- a/contracts/interfaces/ITranche.sol +++ b/contracts/interfaces/ITranche.sol @@ -24,4 +24,8 @@ interface ITranche is IERC20Permit { function interestToken() external view returns (IInterestToken); function interestSupply() external view returns (uint128); + + function underlying() external view returns (IERC20); + + function unlockTimestamp() external view returns (uint256); } diff --git a/contracts/test/TestTranche.sol b/contracts/test/TestTranche.sol new file mode 100644 index 00000000..51d0fa5b --- /dev/null +++ b/contracts/test/TestTranche.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.0; + +import { IERC20 } from "../interfaces/IERC20.sol"; + +contract TestTranche { + IERC20 private _baseToken; + uint256 private _timestamp; + + constructor(address baseToken, uint256 timestamp) { + _baseToken = IERC20(baseToken); + _timestamp = timestamp; + } + + function underlying() external view returns (IERC20) { + return _baseToken; + } + + function unlockTimestamp() external view returns (uint256) { + return _timestamp; + } +} diff --git a/package.json b/package.json index e385b01b..21df9383 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "sim": "sh scripts/load-sim-data.sh && npx hardhat test --config hardhat.config.test.ts ./test/simulation/*.ts", "coverage": "COVERAGE=true NODE_OPTIONS=--max_old_space_size=8192 npx hardhat coverage --solcoverjs \".solcover.js\" --testfiles \"./test/*.ts\"", "test:quick": "npx hardhat test --config hardhat.config.test.ts ./test/*.ts --no-compile", + "unit:test": "npx hardhat test --no-compile --config hardhat.config.test.ts", "load-contracts": "sh scripts/load-balancer-contracts.sh" }, "_moduleAliases": { diff --git a/test/helpers/deployer.ts b/test/helpers/deployer.ts index a77183f8..a9554aeb 100644 --- a/test/helpers/deployer.ts +++ b/test/helpers/deployer.ts @@ -68,6 +68,16 @@ export interface TrancheTestFixture { interestToken: InterestToken; } +export interface TrancheTestFixtureWithBaseAsset { + signer: Signer; + usdc: TestERC20; + positionStub: TestWrappedPosition; + tranche: Tranche; + interestToken: InterestToken; + trancheFactory: TrancheFactory; + bytecodehash: string; +} + export interface YearnShareZapInterface { sharesZapper: ZapYearnShares; signer: Signer; @@ -93,7 +103,7 @@ const deployTestWrappedPosition = async (signer: Signer, address: string) => { return await deployer.deploy(address); }; -const deployUsdc = async (signer: Signer, owner: string) => { +export const deployUsdc = async (signer: Signer, owner: string) => { const deployer = new TestERC20__factory(signer); return await deployer.deploy(owner, "tUSDC", 6); }; @@ -135,24 +145,25 @@ export const deployTrancheFactory = async (signer: Signer) => { return deployTx; }; -export async function loadFixture() { +export async function loadFixtureWithBaseAsset( + baseAsset: TestERC20, + expiry: any +) { // The mainnet weth address won't work unless mainnet deployed const wethAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const [signer] = await ethers.getSigners(); - const signerAddress = (await signer.getAddress()) as string; - const usdc = await deployUsdc(signer, signerAddress); - const yusdc = await deployYusdc(signer, usdc.address, 6); + const yusdc = await deployYusdc(signer, baseAsset.address, 6); const position: YVaultAssetProxy = await deployYasset( signer, yusdc.address, - usdc.address, + baseAsset.address, "eyUSDC", "eyUSDC" ); // deploy and fetch tranche contract const trancheFactory = await deployTrancheFactory(signer); - await trancheFactory.deployTranche(1e10, position.address); + await trancheFactory.deployTranche(expiry, position.address); const eventFilter = trancheFactory.filters.TrancheCreated(null, null, null); const events = await trancheFactory.queryFilter(eventFilter); const trancheAddress = events[0] && events[0].args && events[0].args[0]; @@ -177,7 +188,7 @@ export async function loadFixture() { ); return { signer, - usdc, + usdc: baseAsset, yusdc, position, tranche, @@ -187,6 +198,13 @@ export async function loadFixture() { }; } +export async function loadFixture() { + const [signer] = await ethers.getSigners(); + const signerAddress = (await signer.getAddress()) as string; + const usdc = await deployUsdc(signer, signerAddress); + return await loadFixtureWithBaseAsset(usdc, 1e10); +} + export async function loadEthPoolMainnetFixture() { const wethAddress = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const ywethAddress = "0xac333895ce1A73875CF7B4Ecdc5A743C12f3d82B"; @@ -274,18 +292,18 @@ export async function loadUsdcPoolMainnetFixture() { }; } -export async function loadTestTrancheFixture() { +export async function loadTestTrancheFixtureWithBaseAsset( + baseAsset: TestERC20, + expiration: any +) { const [signer] = await ethers.getSigners(); - const testTokenDeployer = new TestERC20__factory(signer); - const usdc = await testTokenDeployer.deploy("test token", "TEST", 6); - const positionStub: TestWrappedPosition = await deployTestWrappedPosition( signer, - usdc.address + baseAsset.address ); // deploy and fetch tranche contract const trancheFactory = await deployTrancheFactory(signer); - await trancheFactory.deployTranche(1e10, positionStub.address); + await trancheFactory.deployTranche(expiration, positionStub.address); const eventFilter = trancheFactory.filters.TrancheCreated(null, null, null); const events = await trancheFactory.queryFilter(eventFilter); const trancheAddress = events[0] && events[0].args && events[0].args[0]; @@ -297,12 +315,33 @@ export async function loadTestTrancheFixture() { signer ); + const bytecodehash = ethers.utils.solidityKeccak256( + ["bytes"], + [data.bytecode] + ); + return { signer, - usdc, + usdc: baseAsset, positionStub, tranche, interestToken, + trancheFactory, + bytecodehash, + }; +} + +export async function loadTestTrancheFixture() { + const [signer] = await ethers.getSigners(); + const testTokenDeployer = new TestERC20__factory(signer); + const usdc = await testTokenDeployer.deploy("test token", "TEST", 6); + const t = await loadTestTrancheFixtureWithBaseAsset(usdc, 1e10); + return { + signer: t.signer, + usdc: t.usdc, + positionStub: t.positionStub, + tranche: t.tranche, + interestToken: t.interestToken, }; } diff --git a/test/wrappedCoveredPrincipalTokenFactoryTests.ts b/test/wrappedCoveredPrincipalTokenFactoryTests.ts new file mode 100644 index 00000000..459bfa24 --- /dev/null +++ b/test/wrappedCoveredPrincipalTokenFactoryTests.ts @@ -0,0 +1,78 @@ +import { expect } from "chai"; +import { ethers, waffle } from "hardhat"; +import { WrappedCoveredPrincipalTokenFactory } from "typechain/WrappedCoveredPrincipalTokenFactory"; +import { WrappedCoveredPrincipalTokenFactory__factory } from "typechain/factories/WrappedCoveredPrincipalTokenFactory__factory"; +import { TestERC20__factory } from "typechain/factories/TestERC20__factory"; +import { createSnapshot, restoreSnapshot } from "./helpers/snapshots"; +import { loadFixture, FixtureInterface } from "./helpers/deployer"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import data from "../artifacts/contracts/Tranche.sol/Tranche.json"; + +const { provider } = waffle; + +describe("WrappedCoveredPrincipalTokenFactory", function () { + let factory: WrappedCoveredPrincipalTokenFactory; + let signers: SignerWithAddress[]; + let fixture: FixtureInterface; + + before(async function () { + await createSnapshot(provider); + signers = await ethers.getSigners(); + fixture = await loadFixture(); + const bytecodehash = ethers.utils.solidityKeccak256( + ["bytes"], + [data.bytecode] + ); + const deployer = new WrappedCoveredPrincipalTokenFactory__factory( + signers[0] + ); + factory = await deployer.deploy( + fixture.trancheFactory.address, + bytecodehash + ); + }); + after(async () => { + await restoreSnapshot(provider); + }); + + describe("Create Wrapped PrincipalToken", async () => { + let deployer: any; + let contractOwner: any; + + before(async function () { + await createSnapshot(provider); + signers = await ethers.getSigners(); + deployer = new TestERC20__factory(signers[0]); + contractOwner = await ethers.provider.getSigner(signers[1].address); + }); + + it("should fail to create because of zero address of owner", async () => { + const owner = "0x0000000000000000000000000000000000000000"; + const baseToken = await deployer.deploy("Token", "TKN", 18); + const tx = factory + .connect(contractOwner) + .create(baseToken.address, owner, { from: signers[1].address }); + await expect(tx).to.be.revertedWith("WFPF:ZERO_ADDRESS"); + }); + + it("should fail to create because of zero address of base token", async () => { + const owner = signers[2].address; + const baseToken = "0x0000000000000000000000000000000000000000"; + const tx = factory + .connect(contractOwner) + .create(baseToken, owner, { from: signers[1].address }); + await expect(tx).to.be.revertedWith("WFPF:ZERO_ADDRESS"); + }); + + it("should successfully create the wrapped covered token", async () => { + const owner = signers[2].address; + const baseToken = await deployer.deploy("Token", "TKN", 18); + await factory + .connect(contractOwner) + .create(baseToken.address, owner, { from: signers[1].address }); + expect( + (await factory.allWrappedCoveredPrincipalTokens()).length + ).to.equal(1); + }); + }); +}); diff --git a/test/wrappedCoveredPrincipalTokenTests.ts b/test/wrappedCoveredPrincipalTokenTests.ts new file mode 100644 index 00000000..2c44de03 --- /dev/null +++ b/test/wrappedCoveredPrincipalTokenTests.ts @@ -0,0 +1,291 @@ +import { expect } from "chai"; +import { Signer } from "ethers"; +import { ethers, waffle } from "hardhat"; +import { WrappedCoveredPrincipalTokenFactory } from "typechain/WrappedCoveredPrincipalTokenFactory"; +import { WrappedCoveredPrincipalToken } from "typechain/WrappedCoveredPrincipalToken"; +import { WrappedCoveredPrincipalTokenFactory__factory } from "typechain/factories/WrappedCoveredPrincipalTokenFactory__factory"; +import { WrappedCoveredPrincipalToken__factory } from "typechain/factories/WrappedCoveredPrincipalToken__factory"; +import { TestERC20 } from "typechain/TestERC20"; +import { createSnapshot, restoreSnapshot } from "./helpers/snapshots"; +import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signer-with-address"; +import { + deployUsdc, + loadTestTrancheFixtureWithBaseAsset, + TrancheTestFixtureWithBaseAsset, +} from "./helpers/deployer"; +import { TestERC20__factory } from "typechain/factories/TestERC20__factory"; +import { advanceTime, getCurrentTimestamp } from "./helpers/time"; +import { getPermitSignature } from "./helpers/signatures"; +import { ERC20Permit } from "typechain/ERC20Permit"; +import data from "../artifacts/contracts/Tranche.sol/Tranche.json"; + +const { provider } = waffle; + +const initialBalance = ethers.BigNumber.from("2000000"); // 2e9 + +describe("WrappedCoveredPrincipalToken", function () { + let fixture: TrancheTestFixtureWithBaseAsset; + let factory: WrappedCoveredPrincipalTokenFactory; + let coveredToken: WrappedCoveredPrincipalToken; + let signers: SignerWithAddress[]; + let baseToken: TestERC20; + let coveredOwner: string; + let user1: Signer; + let user2: Signer; + let user1Address: string; + let user2Address: string; + let expiration: number; + + before(async function () { + await createSnapshot(provider); + signers = await ethers.getSigners(); + expiration = (await getCurrentTimestamp(provider)) + 10000; + const factoryDeployer = new WrappedCoveredPrincipalTokenFactory__factory( + signers[0] + ); + const coveredTokenDeployer = new WrappedCoveredPrincipalToken__factory( + signers[0] + ); + + const tempUsdc = await deployUsdc( + signers[0], + (await signers[0].getAddress()) as string + ); + // load all related contracts + fixture = await loadTestTrancheFixtureWithBaseAsset(tempUsdc, expiration); + + coveredOwner = signers[1].address; + baseToken = fixture.usdc; + const bytecodehash = ethers.utils.solidityKeccak256( + ["bytes"], + [data.bytecode] + ); + factory = await factoryDeployer.deploy( + fixture.trancheFactory.address, + bytecodehash + ); + await factory.create(baseToken.address, coveredOwner); + coveredToken = coveredTokenDeployer.attach( + (await factory.allWrappedCoveredPrincipalTokens())[0] + ); + + [user1, user2] = await ethers.getSigners(); + user1Address = await user1.getAddress(); + user2Address = await user2.getAddress(); + + // Mint for the users + await fixture.usdc.connect(user1).setBalance(user1Address, initialBalance); + await fixture.usdc.connect(user2).setBalance(user2Address, initialBalance); + // Set approvals on the tranche + await fixture.usdc.connect(user1).approve(fixture.tranche.address, 2e10); + await fixture.usdc.connect(user2).approve(fixture.tranche.address, 2e10); + }); + after(async () => { + await restoreSnapshot(provider); + }); + + describe("Validate Constructor", async () => { + it("Should initialize correctly", async () => { + const adminRole = await coveredToken.ADMIN_ROLE(); + const reclaimRole = await coveredToken.RECLAIM_ROLE(); + expect(await coveredToken.hasRole(adminRole, coveredOwner)).to.true; + expect(await coveredToken.getRoleAdmin(adminRole)).to.be.equal(adminRole); + expect(await coveredToken.getRoleAdmin(reclaimRole)).to.be.equal( + adminRole + ); + expect(await coveredToken.name()).to.equal( + "WrappedtUSDCCovered Principal" + ); + expect(await coveredToken.symbol()).to.equal("WtUSDC"); + expect(await coveredToken.baseToken()).to.equal(baseToken.address); + }); + }); + + describe("Tranche mgt", async () => { + const tokenToMint = ethers.BigNumber.from("2000000000000000000"); + + before(async () => { + await createSnapshot(provider); + await fixture.tranche + .connect(user1) + .deposit(initialBalance, user1Address); + await fixture.tranche + .connect(user2) + .deposit(initialBalance, user2Address); + + // check for correct Interest Token balance + expect(await fixture.interestToken.balanceOf(user1Address)).to.equal( + initialBalance + ); + expect(await fixture.interestToken.balanceOf(user2Address)).to.equal( + initialBalance + ); + + // check for correct Principal Token balance + expect(await fixture.tranche.balanceOf(user1Address)).to.equal( + initialBalance + ); + expect(await fixture.tranche.balanceOf(user2Address)).to.equal( + initialBalance + ); + }); + + after(async () => { + await restoreSnapshot(provider); + }); + + it("should fail to add wrapped position because msg.sender is not the owner", async () => { + const tx = coveredToken + .connect(signers[2]) + .addWrappedPosition(fixture.positionStub.address); + await expect(tx).to.be.revertedWith( + "AccessControl: account 0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc is missing role 0x41444d494e5f524f4c4500000000000000000000000000000000000000000000" + ); + }); + + it("should fail to add wrapped position because baseToken doesn't match", async () => { + const tokenDeployer = new TestERC20__factory(signers[0]); + const token = await tokenDeployer.deploy("Test1", "TOP", 18); + const fakeWrappedPosition = ( + await loadTestTrancheFixtureWithBaseAsset(token, 1e10) + ).positionStub; + const tx = coveredToken + .connect(signers[1]) + .addWrappedPosition(fakeWrappedPosition.address); + await expect(tx).to.be.revertedWith("WFP:INVALID_WP"); + }); + + it("should successfully add the wrapped position", async () => { + await coveredToken + .connect(signers[1]) + .addWrappedPosition(fixture.positionStub.address); + expect((await coveredToken.allWrappedPositions()).length).to.equal(1); + }); + + it("should fail to add wrapped position because it is already added", async () => { + const tx = coveredToken + .connect(signers[1]) + .addWrappedPosition(fixture.positionStub.address); + await expect(tx).to.be.revertedWith("WFP:ALREADY_EXISTS"); + }); + + it("should fail to mint the un allowed wrapped position", async () => { + const tokenDeployer = new TestERC20__factory(signers[0]); + const token = await tokenDeployer.deploy("Test1", "TOP", 18); + const fakeWrappedPosition = ( + await loadTestTrancheFixtureWithBaseAsset(token, 1e10) + ).positionStub; + + const tx = coveredToken + .connect(user1) + .mint(tokenToMint, 1e10, fakeWrappedPosition.address, { + spender: "0x0000000000000000000000000000000000000000", + value: 0, + deadline: 0, + v: 0, + r: ethers.utils.hexZeroPad("0x1f", 32), + s: ethers.utils.hexZeroPad("0x1f", 32), + }); + await expect(tx).to.be.revertedWith("WFP:INVALID_WP"); + }); + + it("should failed to mint the wrapped covered token because position is not expired yet", async () => { + const tx = coveredToken + .connect(user1) + .mint(tokenToMint, expiration, fixture.positionStub.address, { + spender: "0x0000000000000000000000000000000000000000", + value: 0, + deadline: 0, + v: 0, + r: ethers.utils.hexZeroPad("0x1f", 32), + s: ethers.utils.hexZeroPad("0x1f", 32), + }); + await expect(tx).to.be.revertedWith("WFP:POSITION_NOT_EXPIRED"); + }); + + it("should failed to mint the wrapped covered token because allowance not provided", async () => { + const expirationTime = (await fixture.tranche.unlockTimestamp()).add(1); + advanceTime(provider, expirationTime.toNumber()); + const tx = coveredToken + .connect(user1) + .mint(tokenToMint, expiration, fixture.positionStub.address, { + spender: "0x0000000000000000000000000000000000000000", + value: 0, + deadline: 0, + v: 0, + r: ethers.utils.hexZeroPad("0x1f", 32), + s: ethers.utils.hexZeroPad("0x1f", 32), + }); + await expect(tx).to.be.revertedWith("ERC20: insufficient-allowance"); + }); + + it("should successfully mint the wrapped covered token", async () => { + await fixture.tranche + .connect(user1) + .approve(coveredToken.address, initialBalance); + await coveredToken + .connect(user1) + .mint(tokenToMint, expiration, fixture.positionStub.address, { + spender: "0x0000000000000000000000000000000000000000", + value: 0, + deadline: 0, + v: 0, + r: ethers.utils.hexZeroPad("0x1f", 32), + s: ethers.utils.hexZeroPad("0x1f", 32), + }); + expect(await coveredToken.balanceOf(user1Address)).to.equal(tokenToMint); + expect(await fixture.tranche.balanceOf(user1Address)).to.equal(0); + }); + + it("should failed to mint the wrapped covered token as allowance not provide because of invalid permit data", async () => { + const token = fixture.tranche as ERC20Permit; + const sig = await getPermitSignature( + token, + user1Address, + coveredToken.address, + initialBalance, + "1" + ); + const tx = coveredToken + .connect(user2) + .mint(tokenToMint, expiration, fixture.positionStub.address, { + spender: coveredToken.address, + value: initialBalance, + deadline: ethers.constants.MaxUint256, + v: sig.v, + r: sig.r, + s: sig.s, + }); + await expect(tx).to.be.revertedWith("ERC20: invalid-permit"); + }); + + it("should successfully mint the wrapped covered token using permit data", async () => { + const token = fixture.tranche as ERC20Permit; + const sig = await getPermitSignature( + token, + user2Address, + coveredToken.address, + initialBalance, + "1" + ); + await coveredToken + .connect(user2) + .mint(tokenToMint, expiration, fixture.positionStub.address, { + spender: coveredToken.address, + value: initialBalance, + deadline: ethers.constants.MaxUint256, + v: sig.v, + r: sig.r, + s: sig.s, + }); + expect(await coveredToken.balanceOf(user2Address)).to.equal(tokenToMint); + expect(await fixture.tranche.balanceOf(user2Address)).to.equal(0); + }); + + it("should verify the getters output", async () => { + expect(await coveredToken.isAllowedWp(fixture.positionStub.address)).to.be + .true; + expect((await coveredToken.allWrappedPositions()).length).to.be.equal(1); + }); + }); +});