diff --git a/contracts/mainnet/connectors/notional/events.sol b/contracts/mainnet/connectors/notional/events.sol index 5163e18d..cf37ec1e 100644 --- a/contracts/mainnet/connectors/notional/events.sol +++ b/contracts/mainnet/connectors/notional/events.sol @@ -88,4 +88,32 @@ contract Events { ); event LogBatchActionRaw(address indexed account); + + event LogMintSNoteFromBPT(address indexed account, uint256 bptAmount); + + event LogMintSNoteFromETH( + address indexed account, + uint256 noteAmount, + uint256 ethAmount, + uint256 minBPT + ); + + event LogMintSNoteFromWETH( + address indexed account, + uint256 noteAmount, + uint256 wethAmount, + uint256 minBPT + ); + + event LogStartCoolDown(address indexed account); + + event LogStopCoolDown(address indexed account); + + event LogRedeemSNote( + address indexed account, + uint256 sNOTEAmount, + uint256 minWETH, + uint256 minNOTE, + bool redeemWETH + ); } diff --git a/contracts/mainnet/connectors/notional/helpers.sol b/contracts/mainnet/connectors/notional/helpers.sol index 1b18f12e..b551eb27 100644 --- a/contracts/mainnet/connectors/notional/helpers.sol +++ b/contracts/mainnet/connectors/notional/helpers.sol @@ -2,7 +2,7 @@ pragma solidity ^0.7.6; pragma abicoder v2; -import { Token, NotionalInterface, BalanceAction, BalanceActionWithTrades, DepositActionType } from "./interface.sol"; +import { Token, NotionalInterface, StakingInterface, BalanceAction, BalanceActionWithTrades, DepositActionType } from "./interface.sol"; import { Basic } from "../../common/basic.sol"; import { DSMath } from "../../common/math.sol"; import { TokenInterface } from "../../common/interfaces.sol"; @@ -18,6 +18,22 @@ abstract contract Helpers is DSMath, Basic { NotionalInterface internal constant notional = NotionalInterface(0x1344A36A1B56144C3Bc62E7757377D288fDE0369); + /// @dev sNOTE contract address + StakingInterface internal constant staking = + StakingInterface(0x38DE42F4BA8a35056b33A746A6b45bE9B1c3B9d2); + + /// @dev sNOTE balancer pool token address + TokenInterface internal constant bpt = + TokenInterface(0x5122E01D819E58BB2E22528c0D68D310f0AA6FD7); + + /// @dev NOTE token address + TokenInterface internal constant note = + TokenInterface(0xCFEAead4947f0705A14ec42aC3D44129E1Ef3eD5); + + /// @dev WETH token address + TokenInterface internal constant weth = + TokenInterface(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + /// @notice Returns the address of the underlying token for a given currency id, function getAssetOrUnderlyingToken(uint16 currencyId, bool underlying) internal diff --git a/contracts/mainnet/connectors/notional/interface.sol b/contracts/mainnet/connectors/notional/interface.sol index dea1ab6c..4b712527 100644 --- a/contracts/mainnet/connectors/notional/interface.sol +++ b/contracts/mainnet/connectors/notional/interface.sol @@ -2,6 +2,8 @@ pragma solidity ^0.7.6; pragma abicoder v2; +import { TokenInterface } from "../../common/interfaces.sol"; + /// @notice Different types of internal tokens /// - UnderlyingToken: underlying asset for a cToken (except for Ether) /// - cToken: Compound interest bearing token @@ -133,3 +135,26 @@ interface NotionalInterface { BalanceActionWithTrades[] calldata actions ) external payable; } + +interface StakingInterface is TokenInterface { + function mintFromETH(uint256 noteAmount, uint256 minBPT) external payable; + + function mintFromWETH( + uint256 noteAmount, + uint256 wethAmount, + uint256 minBPT + ) external; + + function mintFromBPT(uint256 bptAmount) external; + + function startCoolDown() external; + + function stopCoolDown() external; + + function redeem( + uint256 sNOTEAmount, + uint256 minWETH, + uint256 minNOTE, + bool redeemWETH + ) external; +} diff --git a/contracts/mainnet/connectors/notional/main.sol b/contracts/mainnet/connectors/notional/main.sol index c859b15c..575cc68f 100644 --- a/contracts/mainnet/connectors/notional/main.sol +++ b/contracts/mainnet/connectors/notional/main.sol @@ -655,6 +655,137 @@ abstract contract NotionalResolver is Events, Helpers { ); } + /// @notice Mints sNOTE from the underlying BPT token. + /// @dev Mints sNOTE from the underlying BPT token. + /// @param bptAmount is the amount of BPT to transfer from the msg.sender. + function mintSNoteFromBPT(uint256 bptAmount) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + if (bptAmount == type(uint256).max) + bptAmount = bpt.balanceOf(address(this)); + + approve(bpt, address(staking), bptAmount); + + staking.mintFromBPT(bptAmount); + + _eventName = "LogMintSNoteFromBPT(address,uint256)"; + _eventParam = abi.encode(address(this), bptAmount); + } + + /// @notice Mints sNOTE from some amount of NOTE and ETH + /// @dev Mints sNOTE from some amount of NOTE and ETH + /// @param noteAmount amount of NOTE to transfer into the sNOTE contract + /// @param minBPT slippage parameter to prevent front running + function mintSNoteFromETH( + uint256 noteAmount, + uint256 ethAmount, + uint256 minBPT + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + if (noteAmount == type(uint256).max) + noteAmount = note.balanceOf(address(this)); + + if (ethAmount == type(uint256).max) ethAmount = address(this).balance; + + approve(note, address(staking), noteAmount); + + staking.mintFromETH{ value: ethAmount }(noteAmount, minBPT); + + _eventName = "LogMintSNoteFromETH(address,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), ethAmount, noteAmount, minBPT); + } + + /// @notice Mints sNOTE from some amount of NOTE and WETH + /// @dev Mints sNOTE from some amount of NOTE and WETH + /// @param noteAmount amount of NOTE to transfer into the sNOTE contract + /// @param wethAmount amount of WETH to transfer into the sNOTE contract + /// @param minBPT slippage parameter to prevent front running + function mintSNoteFromWETH( + uint256 noteAmount, + uint256 wethAmount, + uint256 minBPT + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + if (noteAmount == type(uint256).max) + noteAmount = note.balanceOf(address(this)); + + if (wethAmount == type(uint256).max) + wethAmount = weth.balanceOf(address(this)); + + approve(note, address(staking), noteAmount); + approve(weth, address(staking), wethAmount); + + staking.mintFromWETH(noteAmount, wethAmount, minBPT); + + _eventName = "LogMintSNoteFromWETH(address,uint256,uint256,uint256)"; + _eventParam = abi.encode(address(this), noteAmount, wethAmount, minBPT); + } + + /// @notice Begins a cool down period for the sender + /// @dev This is required to redeem tokens + function startCoolDown() + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + staking.startCoolDown(); + + _eventName = "LogStartCoolDown(address)"; + _eventParam = abi.encode(address(this)); + } + + /// @notice Stops a cool down for the sender + /// @dev User must start another cool down period in order to call redeemSNote + function stopCoolDown() + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + staking.stopCoolDown(); + + _eventName = "LogStopCoolDown(address)"; + _eventParam = abi.encode(address(this)); + } + + /// @notice Redeems some amount of sNOTE to underlying constituent tokens (ETH and NOTE). + /// @dev An account must have passed its cool down expiration before they can redeem + /// @param sNOTEAmount amount of sNOTE to redeem + /// @param minWETH slippage protection for ETH/WETH amount + /// @param minNOTE slippage protection for NOTE amount + /// @param redeemWETH true if redeeming to WETH to ETH + function redeemSNote( + uint256 sNOTEAmount, + uint256 minWETH, + uint256 minNOTE, + bool redeemWETH + ) + external + payable + returns (string memory _eventName, bytes memory _eventParam) + { + if (sNOTEAmount == type(uint256).max) + sNOTEAmount = staking.balanceOf(address(this)); + + staking.redeem(sNOTEAmount, minWETH, minNOTE, redeemWETH); + + _eventName = "LogRedeemSNote(address,uint256,uint256,uint256,bool)"; + _eventParam = abi.encode( + address(this), + sNOTEAmount, + minWETH, + minNOTE, + redeemWETH + ); + } + /** * @notice Executes a number of batch actions on the account without getId or setId integration * @dev This method will allow the user to take almost any action on Notional but does not have any diff --git a/test/mainnet/notional/notional.contracts.ts b/test/mainnet/notional/notional.contracts.ts index d9f95919..54809b49 100644 --- a/test/mainnet/notional/notional.contracts.ts +++ b/test/mainnet/notional/notional.contracts.ts @@ -88,10 +88,14 @@ const NOTIONAL_CONTRACT_ABI = [ } ]; +const SNOTE_CONTRACT_ADDRESS = '0x38de42f4ba8a35056b33a746a6b45be9b1c3b9d2'; + const WETH_TOKEN_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; const DAI_TOKEN_ADDRESS = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; const CDAI_TOKEN_ADDRESS = "0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643"; const CETH_TOKEN_ADDRESS = "0x4ddc2d193948926d02f9b1fe9e1daa0718270ed5"; +const BPT_TOKEN_ADDRESS = "0x5122E01D819E58BB2E22528c0D68D310f0AA6FD7"; +const NOTE_TOKEN_ADDRESS = "0xCFEAead4947f0705A14ec42aC3D44129E1Ef3eD5"; const ERC20_TOKEN_ABI = [ "function transfer(address _to, uint256 _value) public returns (bool success)", "function balanceOf(address account) external view returns (uint256)", @@ -101,9 +105,12 @@ const ERC20_TOKEN_ABI = [ export default { NOTIONAL_CONTRACT_ADDRESS, NOTIONAL_CONTRACT_ABI, + SNOTE_CONTRACT_ADDRESS, WETH_TOKEN_ADDRESS, + BPT_TOKEN_ADDRESS, DAI_TOKEN_ADDRESS, CDAI_TOKEN_ADDRESS, CETH_TOKEN_ADDRESS, + NOTE_TOKEN_ADDRESS, ERC20_TOKEN_ABI }; diff --git a/test/mainnet/notional/notional.helpers.ts b/test/mainnet/notional/notional.helpers.ts index 4ba8c422..ad57c8c1 100644 --- a/test/mainnet/notional/notional.helpers.ts +++ b/test/mainnet/notional/notional.helpers.ts @@ -200,6 +200,119 @@ const withdrawLend = async ( await tx.wait() }; +const mintSNoteFromETH = async ( + dsa: any, + authority: any, + referrer: any, + noteAmount: BigNumber, + ethAmount: BigNumber, + minBPT: BigNumber +) => { + const spells = [ + { + connector: "NOTIONAL-TEST-A", + method: "mintSNoteFromETH", + args: [noteAmount, ethAmount, minBPT] + } + ] + + const tx = await dsa.connect(authority).cast(...encodeSpells(spells), referrer.address); + await tx.wait() +} + +const mintSNoteFromWETH = async ( + dsa: any, + authority: any, + referrer: any, + noteAmount: BigNumber, + wethAmount: BigNumber, + minBPT: BigNumber +) => { + const spells = [ + { + connector: "NOTIONAL-TEST-A", + method: "mintSNoteFromWETH", + args: [noteAmount, wethAmount, minBPT] + } + ] + + const tx = await dsa.connect(authority).cast(...encodeSpells(spells), referrer.address); + await tx.wait() +} + +const mintSNoteFromBPT = async ( + dsa: any, + authority: any, + referrer: any, + bptAmount: BigNumber +) => { + const spells = [ + { + connector: "NOTIONAL-TEST-A", + method: "mintSNoteFromBPT", + args: [bptAmount] + } + ] + + const tx = await dsa.connect(authority).cast(...encodeSpells(spells), referrer.address); + await tx.wait() +} + +const startCoolDown = async ( + dsa: any, + authority: any, + referrer: any +) => { + const spells = [ + { + connector: "NOTIONAL-TEST-A", + method: "startCoolDown", + args: [] + } + ] + + const tx = await dsa.connect(authority).cast(...encodeSpells(spells), referrer.address); + await tx.wait() +} + +const stopCoolDown = async ( + dsa: any, + authority: any, + referrer: any +) => { + const spells = [ + { + connector: "NOTIONAL-TEST-A", + method: "stopCoolDown", + args: [] + } + ] + + const tx = await dsa.connect(authority).cast(...encodeSpells(spells), referrer.address); + await tx.wait() +} + +const redeemSNote = async ( + dsa: any, + authority: any, + referrer: any, + sNOTEAmount: BigNumber, + minWETH: BigNumber, + minNOTE: BigNumber, + redeemWETH: boolean +) => { + const spells = [ + { + connector: "NOTIONAL-TEST-A", + method: "redeemSNote", + args: [sNOTEAmount, minWETH, minNOTE, redeemWETH] + } + ] + + const tx = await dsa.connect(authority).cast(...encodeSpells(spells), referrer.address); + await tx.wait() +} + export default { depositCollteral, depositAndMintNToken, @@ -209,5 +322,11 @@ export default { redeemNTokenRaw, redeemNTokenAndWithdraw, redeemNTokenAndDeleverage, - depositCollateralBorrowAndWithdraw + depositCollateralBorrowAndWithdraw, + mintSNoteFromETH, + mintSNoteFromWETH, + mintSNoteFromBPT, + startCoolDown, + stopCoolDown, + redeemSNote }; diff --git a/test/mainnet/notional/notional.test.ts b/test/mainnet/notional/notional.test.ts index 92c78853..304ee253 100644 --- a/test/mainnet/notional/notional.test.ts +++ b/test/mainnet/notional/notional.test.ts @@ -18,6 +18,8 @@ const DAI_WHALE = "0x6dfaf865a93d3b0b5cfd1b4db192d1505676645b"; const CDAI_WHALE = "0x33b890d6574172e93e58528cd99123a88c0756e9"; const ETH_WHALE = "0x7D24796f7dDB17d73e8B1d0A3bbD103FBA2cb2FE"; const CETH_WHALE = "0x1a1cd9c606727a7400bb2da6e4d5c70db5b4cade"; +const WETH_WHALE = "0x6555e1cc97d3cba6eaddebbcd7ca51d75771e0b8"; +const BPT_WHALE = "0x38de42f4ba8a35056b33a746a6b45be9b1c3b9d2"; const MaxUint96 = BigNumber.from("0xffffffffffffffffffffffff"); const DEPOSIT_ASSET = 1; const DEPOSIT_UNDERLYING = 2; @@ -35,12 +37,18 @@ describe("Notional", function () { let instaConnectorsV2: any; let connector: any; let notional: any; + let snote: any; let daiToken: any; let cdaiToken: any; let cethToken: any; + let wethToken: any; + let bptToken: any; + let noteToken: any; let daiWhale: any; let cdaiWhale: any; let cethWhale: any; + let wethWhale: any; + let bptWhale: any; const wallets = provider.getWallets() const [wallet0, wallet1, wallet2, wallet3] = wallets @@ -73,6 +81,14 @@ describe("Notional", function () { method: "hardhat_impersonateAccount", params: [CETH_WHALE] }) + await hre.network.provider.request({ + method: "hardhat_impersonateAccount", + params: [WETH_WHALE] + }) + await hre.network.provider.request({ + method: "hardhat_impersonateAccount", + params: [BPT_WHALE] + }) masterSigner = await getMasterSigner() instaConnectorsV2 = await ethers.getContractAt(abis.core.connectorsV2, addresses.core.connectorsV2); @@ -87,6 +103,11 @@ describe("Notional", function () { contracts.NOTIONAL_CONTRACT_ABI, ethers.provider ); + snote = new ethers.Contract( + contracts.SNOTE_CONTRACT_ADDRESS, + contracts.ERC20_TOKEN_ABI, + ethers.provider + ) daiToken = new ethers.Contract( contracts.DAI_TOKEN_ADDRESS, contracts.ERC20_TOKEN_ABI, @@ -105,7 +126,24 @@ describe("Notional", function () { ethers.provider ); cethWhale = await ethers.getSigner(CETH_WHALE); - dsaWallet0 = await buildDSAv2(wallet0.address) + wethToken = new ethers.Contract( + contracts.WETH_TOKEN_ADDRESS, + contracts.ERC20_TOKEN_ABI, + ethers.provider + ); + wethWhale = await ethers.getSigner(WETH_WHALE); + bptToken = new ethers.Contract( + contracts.BPT_TOKEN_ADDRESS, + contracts.ERC20_TOKEN_ABI, + ethers.provider + ); + bptWhale = await ethers.getSigner(BPT_WHALE); + noteToken = new ethers.Contract( + contracts.NOTE_TOKEN_ADDRESS, + contracts.ERC20_TOKEN_ABI, + ethers.provider + ) + dsaWallet0 = await buildDSAv2(wallet0.address); }); describe("Deposit Tests", function () { @@ -395,4 +433,81 @@ describe("Notional", function () { expect(after[0][3], "expect fDAI debt balance to go down after deleverage").to.be.lte(ethers.utils.parseUnits("-2000000", 0)); }); }); + + describe("Staking Tests", function () { + it("test_stake_ETH", async function () { + const depositAmount = ethers.utils.parseEther("1"); + await wallet0.sendTransaction({ + to: dsaWallet0.address, + value: depositAmount + }); + expect(await snote.balanceOf(dsaWallet0.address), "expect 0 initial sNOTE balance").to.be.equal(0); + await helpers.mintSNoteFromETH(dsaWallet0, wallet0, wallet1, BigNumber.from(0), depositAmount, BigNumber.from(0)); + expect(await snote.balanceOf(dsaWallet0.address), "expect sNOTE balance to increase").to.be.gte(ethers.utils.parseEther("297")) + }); + + it("test_stake_WETH", async function () { + const depositAmount = ethers.utils.parseEther("1"); + await wethToken.connect(wethWhale).transfer(dsaWallet0.address, depositAmount); + expect(await snote.balanceOf(dsaWallet0.address), "expect 0 initial sNOTE balance").to.be.equal(0); + await helpers.mintSNoteFromWETH(dsaWallet0, wallet0, wallet1, BigNumber.from(0), depositAmount, BigNumber.from(0)); + expect(await snote.balanceOf(dsaWallet0.address), "expect sNOTE balance to increase").to.be.gte(ethers.utils.parseEther("297")) + }); + + it("test_stake_BPT", async function () { + const depositAmount = ethers.utils.parseEther("1"); + await wallet0.sendTransaction({ + to: bptWhale.address, + value: depositAmount + }); + await bptToken.connect(bptWhale).transfer(dsaWallet0.address, depositAmount); + expect(await snote.balanceOf(dsaWallet0.address), "expect 0 initial sNOTE balance").to.be.equal(0); + await helpers.mintSNoteFromBPT(dsaWallet0, wallet0, wallet1, depositAmount); + expect(await snote.balanceOf(dsaWallet0.address), "expect sNOTE balance to increase").to.be.eq(depositAmount) + }); + + it("test_unstake_success", async function () { + const depositAmount = ethers.utils.parseEther("1"); + await wallet0.sendTransaction({ + to: bptWhale.address, + value: depositAmount + }); + await bptToken.connect(bptWhale).transfer(dsaWallet0.address, depositAmount); + await helpers.mintSNoteFromBPT(dsaWallet0, wallet0, wallet1, depositAmount); + await helpers.startCoolDown(dsaWallet0, wallet0, wallet1); + // Skip ahead 16 days + await hre.network.provider.send("evm_increaseTime", [1382400]) + await hre.network.provider.send("evm_mine") + await helpers.redeemSNote( + dsaWallet0, + wallet0, + wallet1, + ethers.constants.MaxUint256, + BigNumber.from(0), + BigNumber.from(0), + true + ); + expect(await noteToken.balanceOf(dsaWallet0.address)).to.be.gte(ethers.utils.parseUnits("50000000000", 0)); + expect(await provider.getBalance(dsaWallet0.address)).to.be.gte(ethers.utils.parseUnits("32500000000000000", 0)) + }); + + it("test_unstable_failure", async function () { + const depositAmount = ethers.utils.parseEther("1"); + await wallet0.sendTransaction({ + to: bptWhale.address, + value: depositAmount + }); + await bptToken.connect(bptWhale).transfer(dsaWallet0.address, depositAmount); + await helpers.mintSNoteFromBPT(dsaWallet0, wallet0, wallet1, depositAmount); + await expect(helpers.redeemSNote( + dsaWallet0, + wallet0, + wallet1, + ethers.constants.MaxUint256, + BigNumber.from(0), + BigNumber.from(0), + true + )).to.be.revertedWith("Not in Redemption Window"); + }); + }); });