diff --git a/contracts/fund/PrimaryMarketV5.sol b/contracts/fund/PrimaryMarketV5.sol index 0d5e2bd9..71f3eaf5 100644 --- a/contracts/fund/PrimaryMarketV5.sol +++ b/contracts/fund/PrimaryMarketV5.sol @@ -122,6 +122,30 @@ contract PrimaryMarketV5 is IPrimaryMarketV5, ReentrancyGuard, ITrancheIndexV2, } } + /// @notice Calculate the amount of underlying tokens to create at least the given QUEEN amount. + /// This only works with non-empty fund for simplicity. + /// @param minOutQ Minimum received QUEEN amount + /// @return underlying Underlying amount that should be used for creation + function getCreationForQ(uint256 minOutQ) external view override returns (uint256 underlying) { + // Assume: + // minOutQ * fundUnderlying = a * fundEquivalentTotalQ - b + // where a and b are integers and 0 <= b < fundEquivalentTotalQ + // Then + // underlying = a + // getCreation(underlying) + // = floor(a * fundEquivalentTotalQ / fundUnderlying) + // >= floor((a * fundEquivalentTotalQ - b) / fundUnderlying) + // = minOutQ + // getCreation(underlying - 1) + // = floor((a * fundEquivalentTotalQ - fundEquivalentTotalQ) / fundUnderlying) + // < (a * fundEquivalentTotalQ - b) / fundUnderlying + // = minOutQ + uint256 fundUnderlying = IFundV3(fund).getTotalUnderlying(); + uint256 fundEquivalentTotalQ = IFundV3(fund).getEquivalentTotalQ(); + require(fundEquivalentTotalQ > 0, "Cannot calculate creation for empty fund"); + return minOutQ.mul(fundUnderlying).add(fundEquivalentTotalQ - 1).div(fundEquivalentTotalQ); + } + function _getRedemption(uint256 inQ) private view returns (uint256 underlying) { uint256 fundUnderlying = IFundV3(fund).getTotalUnderlying(); uint256 fundEquivalentTotalQ = IFundV3(fund).getEquivalentTotalQ(); @@ -139,6 +163,39 @@ contract PrimaryMarketV5 is IPrimaryMarketV5, ReentrancyGuard, ITrancheIndexV2, underlying = _getRedemption(inQ - feeQ); } + /// @notice Calculate the amount of QUEEN that can be redeemed for at least the given amount + /// of underlying tokens. + /// @dev The return value may not be the minimum solution due to rounding errors. + /// @param minUnderlying Minimum received underlying amount + /// @return inQ QUEEN amount that should be redeemed + function getRedemptionForUnderlying( + uint256 minUnderlying + ) external view override returns (uint256 inQ) { + // Assume: + // minUnderlying * fundEquivalentTotalQ = a * fundUnderlying - b + // a * 1e18 = c * (1e18 - redemptionFeeRate) + d + // where + // a, b, c, d are integers + // 0 <= b < fundUnderlying + // 0 <= d < 1e18 - redemeptionFeeRate + // Then + // inQAfterFee = a + // inQ = c + // getRedemption(inQ).underlying + // = floor((c - floor(c * redemptionFeeRate / 1e18)) * fundUnderlying / fundEquivalentTotalQ) + // = floor(ceil(c * (1e18 - redemptionFeeRate) / 1e18) * fundUnderlying / fundEquivalentTotalQ) + // = floor(((c * (1e18 - redemptionFeeRate) + d) / 1e18) * fundUnderlying / fundEquivalentTotalQ) + // = floor(a * fundUnderlying / fundEquivalentTotalQ) + // => floor((a * fundUnderlying - b) / fundEquivalentTotalQ) + // = minUnderlying + uint256 fundUnderlying = IFundV3(fund).getTotalUnderlying(); + uint256 fundEquivalentTotalQ = IFundV3(fund).getEquivalentTotalQ(); + uint256 inQAfterFee = minUnderlying.mul(fundEquivalentTotalQ).add(fundUnderlying - 1).div( + fundUnderlying + ); + return inQAfterFee.divideDecimal(1e18 - redemptionFeeRate); + } + /// @notice Calculate the result of a split. /// @param inQ QUEEN amount to be split /// @return outB Received BISHOP amount @@ -148,6 +205,19 @@ contract PrimaryMarketV5 is IPrimaryMarketV5, ReentrancyGuard, ITrancheIndexV2, outB = outR.mul(_weightB); } + /// @notice Calculate the amount of QUEEN that can be split into at least the given amount of + /// BISHOP and ROOK. + /// @param minOutR Received ROOK amount + /// @return inQ QUEEN amount that should be split + /// @return outB Received BISHOP amount + function getSplitForR( + uint256 minOutR + ) external view override returns (uint256 inQ, uint256 outB) { + uint256 splitRatio = IFundV3(fund).splitRatio(); + outB = minOutR.mul(_weightB); + inQ = minOutR.mul(1e18).add(splitRatio.sub(1)).div(splitRatio); + } + /// @notice Calculate the result of a merge. /// @param inB Spent BISHOP amount /// @return inR Spent ROOK amount @@ -167,6 +237,22 @@ contract PrimaryMarketV5 is IPrimaryMarketV5, ReentrancyGuard, ITrancheIndexV2, } } + /// @notice Calculate the result of a merge using ROOK. + /// @param inR Spent ROOK amount + /// @return inB Spent BISHOP amount + /// @return outQ Received QUEEN amount + /// @return feeQ QUEEN amount charged as merge fee + function getMergeByR( + uint256 inR + ) public view override returns (uint256 inB, uint256 outQ, uint256 feeQ) { + require(!IFundV5(fund).frozen(), "Fund frozen"); + inB = inR.mul(_weightB); + uint256 splitRatio = IFundV5(fund).splitRatio(); + uint256 outQBeforeFee = inR.divideDecimal(splitRatio); + feeQ = outQBeforeFee.multiplyDecimal(mergeFeeRate); + outQ = outQBeforeFee.sub(feeQ); + } + /// @notice Return index of the first queued redemption that cannot be claimed now. /// Users can use this function to determine which indices can be passed to /// `claimRedemptions()`. diff --git a/contracts/fund/WstETHPrimaryMarketRouter.sol b/contracts/fund/WstETHPrimaryMarketRouter.sol index 96e4cd0a..a3b71c24 100644 --- a/contracts/fund/WstETHPrimaryMarketRouter.sol +++ b/contracts/fund/WstETHPrimaryMarketRouter.sol @@ -9,8 +9,9 @@ import "../interfaces/IPrimaryMarketV5.sol"; import "../interfaces/IFundV3.sol"; import "../interfaces/IWstETH.sol"; import "../interfaces/ITrancheIndexV2.sol"; +import "../interfaces/IStableSwap.sol"; -contract WstETHPrimaryMarketRouter is ITrancheIndexV2 { +contract WstETHPrimaryMarketRouter is IStableSwapCore, ITrancheIndexV2 { using SafeERC20 for IERC20; IPrimaryMarketV5 public immutable primaryMarket; @@ -28,6 +29,51 @@ contract WstETHPrimaryMarketRouter is ITrancheIndexV2 { _tokenB = fund_.tokenB(); } + /// @dev Get redemption with StableSwap getQuoteOut interface. + function getQuoteOut(uint256 baseIn) external view override returns (uint256 quoteOut) { + (quoteOut, ) = primaryMarket.getRedemption(baseIn); + } + + /// @dev Get creation for QUEEN with StableSwap getQuoteIn interface. + function getQuoteIn(uint256 baseOut) external view override returns (uint256 quoteIn) { + quoteIn = primaryMarket.getCreationForQ(baseOut); + } + + /// @dev Get creation with StableSwap getBaseOut interface. + function getBaseOut(uint256 quoteIn) external view override returns (uint256 baseOut) { + baseOut = primaryMarket.getCreation(quoteIn); + } + + /// @dev Get redemption for underlying with StableSwap getBaseIn interface. + function getBaseIn(uint256 quoteOut) external view override returns (uint256 baseIn) { + baseIn = primaryMarket.getRedemptionForUnderlying(quoteOut); + } + + /// @dev Create QUEEN with StableSwap buy interface. + /// Underlying should have already been sent to this contract + function buy( + uint256 version, + uint256 baseOut, + address recipient, + bytes calldata + ) external override returns (uint256 realBaseOut) { + uint256 routerQuoteBalance = IERC20(_wstETH).balanceOf(address(this)); + IERC20(_wstETH).safeTransfer(address(primaryMarket), routerQuoteBalance); + realBaseOut = primaryMarket.create(recipient, baseOut, version); + } + + /// @dev Redeem QUEEN with StableSwap sell interface. + /// QUEEN should have already been sent to this contract + function sell( + uint256 version, + uint256 quoteOut, + address recipient, + bytes calldata + ) external override returns (uint256 realQuoteOut) { + uint256 routerBaseBalance = fund.trancheBalanceOf(TRANCHE_Q, address(this)); + realQuoteOut = primaryMarket.redeem(recipient, routerBaseBalance, quoteOut, version); + } + function create( address recipient, bool needWrap, diff --git a/contracts/interfaces/IPrimaryMarketV5.sol b/contracts/interfaces/IPrimaryMarketV5.sol index 20c5f136..0def3e8d 100644 --- a/contracts/interfaces/IPrimaryMarketV5.sol +++ b/contracts/interfaces/IPrimaryMarketV5.sol @@ -6,12 +6,22 @@ interface IPrimaryMarketV5 { function getCreation(uint256 underlying) external view returns (uint256 outQ); + function getCreationForQ(uint256 minOutQ) external view returns (uint256 underlying); + function getRedemption(uint256 inQ) external view returns (uint256 underlying, uint256 fee); - function getSplit(uint256 inQ) external view returns (uint256 outB, uint256 outQ); + function getRedemptionForUnderlying(uint256 minUnderlying) external view returns (uint256 inQ); + + function getSplit(uint256 inQ) external view returns (uint256 outB, uint256 outR); + + function getSplitForR(uint256 minOutR) external view returns (uint256 inQ, uint256 outB); function getMerge(uint256 inB) external view returns (uint256 inR, uint256 outQ, uint256 feeQ); + function getMergeByR( + uint256 inR + ) external view returns (uint256 inB, uint256 outQ, uint256 feeQ); + function canBeRemovedFromFund() external view returns (bool); function create( diff --git a/contracts/swap/FlashSwapRouterV3.sol b/contracts/swap/FlashSwapRouterV3.sol new file mode 100644 index 00000000..58b816e4 --- /dev/null +++ b/contracts/swap/FlashSwapRouterV3.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity >=0.6.10 <0.8.0; + +import "@openzeppelin/contracts/math/SafeMath.sol"; +import "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +import "../interfaces/ITranchessSwapCallee.sol"; +import "../interfaces/IFundV5.sol"; +import "../interfaces/IPrimaryMarketV5.sol"; +import "../interfaces/ISwapRouter.sol"; +import "../interfaces/ITrancheIndexV2.sol"; +import "../interfaces/IWstETH.sol"; + +/// @title Tranchess Flash Swap Router +/// @notice Router for stateless execution of flash swaps against Tranchess stable swaps +contract FlashSwapRouterV3 is ITranchessSwapCallee, ITrancheIndexV2, Ownable { + using SafeMath for uint256; + using SafeERC20 for IERC20; + + event SwapRook( + address indexed recipient, + uint256 baseIn, + uint256 quoteIn, + uint256 baseOut, + uint256 quoteOut + ); + + ISwapRouter public immutable tranchessRouter; + + constructor(address tranchessRouter_) public { + tranchessRouter = ISwapRouter(tranchessRouter_); + } + + /// @dev Only meant for an off-chain client to call with eth_call. + function getBuyR( + IFundV5 fund, + bool needWrap, + address queenSwapOrPrimaryMarketRouter, + address tokenQuote, + uint256 outR + ) external returns (uint256 quoteDelta, uint256 rookDelta) { + (uint256 inQ, uint256 outB) = IPrimaryMarketV5(fund.primaryMarket()).getSplitForR(outR); + uint256 totalQuoteAmount = IStableSwapCoreInternalRevertExpected( + queenSwapOrPrimaryMarketRouter + ).getQuoteIn(inQ); + // Calculate the amount of quote asset for selling BISHOP + IStableSwap tranchessPair = tranchessRouter.getSwap(fund.tokenB(), tokenQuote); + uint256 quoteAmount = tranchessPair.getQuoteOut(outB); + // Calculate the user's portion of the payment to Tranchess swap + quoteDelta = totalQuoteAmount.sub(quoteAmount); + if (needWrap) { + quoteDelta = IWstETH(tokenQuote).getStETHByWstETH(quoteDelta).add(1); + } + // Calculate creation of borrowed underlying for QUEEN + uint256 outQ = IStableSwapCoreInternalRevertExpected(queenSwapOrPrimaryMarketRouter) + .getBaseOut(totalQuoteAmount); + // Calculate the split result of QUEEN into BISHOP and ROOK + (, rookDelta) = IPrimaryMarketV5(fund.primaryMarket()).getSplit(outQ); + } + + /// @dev Only meant for an off-chain client to call with eth_call. + function getSellR( + IFundV5 fund, + bool needUnwrap, + address queenSwapOrPrimaryMarketRouter, + address tokenQuote, + uint256 inR + ) external returns (uint256 quoteDelta, uint256 rookDelta) { + rookDelta = inR; + // Calculate merge result of BISHOP and ROOK into QUEEN + (uint256 inB, uint256 outQ, ) = IPrimaryMarketV5(fund.primaryMarket()).getMergeByR(inR); + uint256 quoteAmount = IStableSwap(tranchessRouter.getSwap(fund.tokenB(), tokenQuote)) + .getQuoteIn(inB); + // Calculate the redemption from QUEEN to underlying + uint256 totalQuoteAmount = IStableSwapCoreInternalRevertExpected( + queenSwapOrPrimaryMarketRouter + ).getQuoteOut(outQ); + // Calculate the rest of quote asset to user + quoteDelta = totalQuoteAmount.sub(quoteAmount); + if (needUnwrap) { + quoteDelta = IWstETH(tokenQuote).getStETHByWstETH(quoteDelta); + } + } + + function buyR( + IFundV5 fund, + bool needWrap, + address queenSwapOrPrimaryMarketRouter, + uint256 maxQuote, + address recipient, + address tokenQuote, + uint256 version, + uint256 outR + ) external { + (uint256 inQ, uint256 outB) = IPrimaryMarketV5(fund.primaryMarket()).getSplitForR(outR); + // Calculate the exact amount of quote asset to pay + uint256 totalQuoteAmount = IStableSwapCoreInternalRevertExpected( + queenSwapOrPrimaryMarketRouter + ).getQuoteIn(inQ); + // Arrange the stable swap path + IStableSwap tranchessPair = tranchessRouter.getSwap(fund.tokenB(), tokenQuote); + // Calculate the amount of quote asset for selling BISHOP + uint256 quoteAmount = tranchessPair.getQuoteOut(outB); + // Send the user's portion of the payment to Tranchess swap + uint256 resultAmount = totalQuoteAmount.sub(quoteAmount); + if (needWrap) { + address stETH = IWstETH(tokenQuote).stETH(); + uint256 unwrappedAmount = IWstETH(tokenQuote).getStETHByWstETH(resultAmount).add(1); + require(unwrappedAmount <= maxQuote, "Excessive input"); + IERC20(stETH).safeTransferFrom(msg.sender, address(this), unwrappedAmount); + IERC20(stETH).approve(tokenQuote, unwrappedAmount); + resultAmount = IWstETH(tokenQuote).wrap(unwrappedAmount); + totalQuoteAmount = quoteAmount.add(resultAmount); + } else { + require(resultAmount <= maxQuote, "Excessive input"); + IERC20(tokenQuote).safeTransferFrom(msg.sender, address(this), resultAmount); + } + bytes memory data = abi.encode( + fund, + queenSwapOrPrimaryMarketRouter, + totalQuoteAmount, + recipient, + version + ); + tranchessPair.sell(version, quoteAmount, address(this), data); + emit SwapRook(recipient, 0, resultAmount, outR, 0); + } + + function sellR( + IFundV5 fund, + bool needUnwrap, + address queenSwapOrPrimaryMarketRouter, + uint256 minQuote, + address recipient, + address tokenQuote, + uint256 version, + uint256 inR + ) external { + // Calculate merge result of BISHOP and ROOK into QUEEN + (uint256 inB, , ) = IPrimaryMarketV5(fund.primaryMarket()).getMergeByR(inR); + // Send the user's ROOK to this router + fund.trancheTransferFrom(TRANCHE_R, msg.sender, address(this), inR, version); + bytes memory data = abi.encode( + fund, + queenSwapOrPrimaryMarketRouter, + minQuote, + recipient, + version + ); + tranchessRouter.getSwap(fund.tokenB(), tokenQuote).buy(version, inB, address(this), data); + // Send the rest of quote asset to user + uint256 resultAmount = IERC20(tokenQuote).balanceOf(address(this)); + if (needUnwrap) { + uint256 unwrappedAmount = IWstETH(tokenQuote).unwrap(resultAmount); + require(unwrappedAmount >= minQuote, "Insufficient output"); + IERC20(IWstETH(tokenQuote).stETH()).safeTransfer(recipient, unwrappedAmount); + } else { + require(resultAmount >= minQuote, "Insufficient output"); + IERC20(tokenQuote).safeTransfer(recipient, resultAmount); + } + emit SwapRook(recipient, inR, 0, 0, resultAmount); + } + + function tranchessSwapCallback( + uint256 baseOut, + uint256 quoteOut, + bytes calldata data + ) external override { + ( + IFundV5 fund, + address queenSwapOrPrimaryMarketRouter, + uint256 expectQuoteAmount, + address recipient, + uint256 version + ) = abi.decode(data, (IFundV5, address, uint256, address, uint256)); + address tokenQuote = IStableSwap(msg.sender).quoteAddress(); + require( + msg.sender == address(tranchessRouter.getSwap(tokenQuote, fund.tokenB())), + "Tranchess Pair check failed" + ); + if (baseOut > 0) { + require(quoteOut == 0, "Unidirectional check failed"); + uint256 quoteAmount = IStableSwap(msg.sender).getQuoteIn(baseOut); + // Merge BISHOP and ROOK into QUEEN + uint256 outQ = IPrimaryMarketV5(fund.primaryMarket()).merge( + queenSwapOrPrimaryMarketRouter, + baseOut, + version + ); + // Redeem or swap QUEEN for underlying + uint256 underlyingAmount = IStableSwapCoreInternalRevertExpected( + queenSwapOrPrimaryMarketRouter + ).getQuoteOut(outQ); + IStableSwapCoreInternalRevertExpected(queenSwapOrPrimaryMarketRouter).sell( + version, + underlyingAmount, + address(this), + "" + ); + // Send back quote asset to tranchess swap + IERC20(tokenQuote).safeTransfer(msg.sender, quoteAmount); + } else { + address tokenUnderlying = fund.tokenUnderlying(); + // Create or swap borrowed underlying for QUEEN + uint256 outQ = IStableSwapCoreInternalRevertExpected(queenSwapOrPrimaryMarketRouter) + .getBaseOut(expectQuoteAmount); + IERC20(tokenUnderlying).safeTransfer(queenSwapOrPrimaryMarketRouter, expectQuoteAmount); + outQ = IStableSwapCoreInternalRevertExpected(queenSwapOrPrimaryMarketRouter).buy( + version, + outQ, + address(this), + "" + ); + // Split QUEEN into BISHOP and ROOK + (uint256 outB, uint256 outR) = IPrimaryMarketV5(fund.primaryMarket()).split( + address(this), + outQ, + version + ); + // Send back BISHOP to tranchess swap + fund.trancheTransfer(TRANCHE_B, msg.sender, outB, version); + // Send ROOK to user + fund.trancheTransfer(TRANCHE_R, recipient, outR, version); + } + } +} diff --git a/contracts/swap/WstETHWrappingSwap.sol b/contracts/swap/WstETHWrappingSwap.sol index b29dd670..3a72862f 100644 --- a/contracts/swap/WstETHWrappingSwap.sol +++ b/contracts/swap/WstETHWrappingSwap.sol @@ -3,12 +3,14 @@ pragma solidity >=0.6.10 <0.8.0; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/SafeERC20.sol"; +import "@openzeppelin/contracts/math/SafeMath.sol"; import "../interfaces/IStableSwap.sol"; import "../interfaces/IWstETH.sol"; import "../utils/SafeDecimalMath.sol"; contract WstETHWrappingSwap is IStableSwap { using SafeERC20 for IERC20; + using SafeMath for uint256; using SafeDecimalMath for uint256; address public immutable wstETH; // Base @@ -24,7 +26,7 @@ contract WstETHWrappingSwap is IStableSwap { } function getQuoteIn(uint256 baseOut) external view override returns (uint256 quoteIn) { - quoteIn = IWstETH(wstETH).getStETHByWstETH(baseOut); + quoteIn = IWstETH(wstETH).getStETHByWstETH(baseOut).add(1); } function getBaseOut(uint256 quoteIn) external view override returns (uint256 baseOut) { @@ -32,7 +34,7 @@ contract WstETHWrappingSwap is IStableSwap { } function getBaseIn(uint256 quoteOut) external view override returns (uint256 baseIn) { - baseIn = IWstETH(wstETH).getWstETHByStETH(quoteOut); + baseIn = IWstETH(wstETH).getWstETHByStETH(quoteOut).add(1); } function buy( diff --git a/test/flashSwapRouterV3.ts b/test/flashSwapRouterV3.ts new file mode 100644 index 00000000..3f8c2709 --- /dev/null +++ b/test/flashSwapRouterV3.ts @@ -0,0 +1,387 @@ +import { expect } from "chai"; +import { BigNumber, Contract, Wallet } from "ethers"; +import type { Fixture, MockProvider } from "ethereum-waffle"; +import { waffle, ethers } from "hardhat"; +const { loadFixture } = waffle; +const { parseEther } = ethers.utils; +import { deployMockForName } from "./mock"; +import { + TRANCHE_Q, + TRANCHE_B, + TRANCHE_R, + DAY, + WEEK, + SETTLEMENT_TIME, + FixtureWalletMap, + advanceBlockAtTime, +} from "./utils"; + +const UNIT = parseEther("1"); +const WEIGHT_B = 9; +const REDEMPTION_FEE_BPS = 100; +const MERGE_FEE_BPS = 75; +const AMPL = 80; +const FEE_RATE = parseEther("0.03"); +const ADMIN_FEE_RATE = parseEther("0.4"); + +const INIT_PRICE = parseEther("1.1"); +const INIT_SPLIT_RATIO = parseEther("0.11"); +const USER_STETH = parseEther("1000"); +const LP_B = parseEther("10000"); +const LP_STETH = parseEther("11000"); + +describe("FlashSwapRouterV3", function () { + this.timeout(60000); + + interface FixtureData { + readonly wallets: FixtureWalletMap; + readonly steth: Contract; + readonly wsteth: Contract; + readonly fund: Contract; + readonly stableSwap: Contract; + readonly primaryMarketRouter: Contract; + readonly flashSwapRouter: Contract; + } + + let currentFixture: Fixture; + let fixtureData: FixtureData; + + let user1: Wallet; + let user2: Wallet; + let owner: Wallet; + let addr1: string; + let addr2: string; + let steth: Contract; + let wsteth: Contract; + let fund: Contract; + let stableSwap: Contract; + let primaryMarketRouter: Contract; + let flashSwapRouter: Contract; + + async function deployFixture(_wallets: Wallet[], provider: MockProvider): Promise { + const [user1, user2, owner, feeCollector] = provider.getWallets(); + + const startTimestamp = (await ethers.provider.getBlock("latest")).timestamp; + const lastDay = Math.ceil(startTimestamp / WEEK) * WEEK + WEEK + SETTLEMENT_TIME; + const startDay = lastDay + DAY; + await advanceBlockAtTime(lastDay + DAY / 2); + + const MockStETH = await ethers.getContractFactory("MockToken"); + const steth = await MockStETH.connect(owner).deploy("stETH", "stETH", 18); + await steth.mint(user1.address, USER_STETH.mul(2)); + await steth.mint(user2.address, USER_STETH); + + const MockWstETH = await ethers.getContractFactory("MockWstETH"); + const wsteth = await MockWstETH.connect(owner).deploy(steth.address); + await wsteth.update(UNIT); + await steth.connect(user1).approve(wsteth.address, USER_STETH); + await steth.connect(user2).approve(wsteth.address, USER_STETH); + await wsteth.connect(user1).wrap(USER_STETH); + await wsteth.connect(user2).wrap(USER_STETH); + + const twapOracle = await deployMockForName(owner, "ITwapOracleV2"); + await twapOracle.mock.getTwap.withArgs(lastDay).returns(INIT_PRICE); + await twapOracle.mock.getLatest.returns(INIT_PRICE); + const aprOracle = await deployMockForName(owner, "IAprOracle"); + await aprOracle.mock.capture.returns(0); + + const fundAddress = ethers.utils.getContractAddress({ + from: owner.address, + nonce: (await owner.getTransactionCount("pending")) + 3, + }); + const primaryMarketAddress = ethers.utils.getContractAddress({ + from: owner.address, + nonce: (await owner.getTransactionCount("pending")) + 4, + }); + const Share = (await ethers.getContractFactory("ShareV2")).connect(owner); + const shareQ = await Share.deploy("wstETH Queen", "QUEEN", fundAddress, TRANCHE_Q); + const shareB = await Share.deploy("wstETH Bishop", "BISHOP", fundAddress, TRANCHE_B); + const shareR = await Share.deploy("wstETH Rook", "ROOK", fundAddress, TRANCHE_R); + const Fund = await ethers.getContractFactory("FundV5"); + const fund = await Fund.connect(owner).deploy([ + WEIGHT_B, + 365 * DAY, + wsteth.address, + 18, + shareQ.address, + shareB.address, + shareR.address, + primaryMarketAddress, + ethers.constants.AddressZero, + twapOracle.address, + aprOracle.address, + feeCollector.address, + ]); + const PrimaryMarket = await ethers.getContractFactory("PrimaryMarketV5"); + const primaryMarket = await PrimaryMarket.connect(owner).deploy( + fund.address, + parseEther("0.0001").mul(REDEMPTION_FEE_BPS), + parseEther("0.0001").mul(MERGE_FEE_BPS), + BigNumber.from(1).shl(256).sub(1), + true + ); + const PrimaryMarketRouter = await ethers.getContractFactory("WstETHPrimaryMarketRouter"); + const primaryMarketRouter = await PrimaryMarketRouter.connect(owner).deploy( + primaryMarket.address + ); + await fund.initialize(INIT_SPLIT_RATIO, parseEther("1"), parseEther("1"), 0); + + const chessSchedule = await deployMockForName(owner, "ChessSchedule"); + await chessSchedule.mock.getRate.returns(UNIT); + const chessController = await deployMockForName(owner, "ChessControllerV5"); + await chessController.mock.getFundRelativeWeight.returns(UNIT); + const votingEscrow = await deployMockForName(owner, "IVotingEscrow"); + await votingEscrow.mock.balanceOf.returns(0); + await votingEscrow.mock.totalSupply.returns(1); + const swapBonus = await deployMockForName(owner, "SwapBonus"); + await swapBonus.mock.bonusToken.returns(ethers.constants.AddressZero); + await swapBonus.mock.getBonus.returns(0); + + const lpTokenAddress = ethers.utils.getContractAddress({ + from: owner.address, + nonce: (await owner.getTransactionCount("pending")) + 1, + }); + const StableSwap = await ethers.getContractFactory("WstETHBishopStableSwap"); + const stableSwap = await StableSwap.connect(owner).deploy( + lpTokenAddress, + fund.address, + wsteth.address, + 18, + AMPL, + feeCollector.address, + FEE_RATE, + ADMIN_FEE_RATE + ); + const LiquidityGauge = await ethers.getContractFactory("LiquidityGauge"); + await LiquidityGauge.connect(owner).deploy( + "LP Token", + "LP", + stableSwap.address, + chessSchedule.address, + chessController.address, + fund.address, + votingEscrow.address, + swapBonus.address, + 0 + ); + + const wstETH = await deployMockForName(owner, "IWstETH"); + await wstETH.mock.stETH.returns(ethers.constants.AddressZero); + const SwapRouter = await ethers.getContractFactory("SwapRouter"); + const swapRouter = await SwapRouter.connect(owner).deploy(wstETH.address); + await swapRouter.addSwap(shareB.address, wsteth.address, stableSwap.address); + const FlashSwapRouter = await ethers.getContractFactory("FlashSwapRouterV3"); + const flashSwapRouter = await FlashSwapRouter.connect(owner).deploy(swapRouter.address); + + await wsteth.connect(user1).approve(primaryMarketRouter.address, USER_STETH); + await wsteth.connect(user2).approve(primaryMarketRouter.address, USER_STETH); + await wsteth.connect(user1).approve(flashSwapRouter.address, USER_STETH); + await wsteth.connect(user2).approve(flashSwapRouter.address, USER_STETH); + await steth.connect(user1).approve(flashSwapRouter.address, USER_STETH); + + // Add initial liquidity + const initQ = LP_B.mul(UNIT).div(INIT_SPLIT_RATIO); + const initWstETH = initQ; + await steth.mint(owner.address, initWstETH); + await steth.approve(wsteth.address, initWstETH); + await wsteth.wrap(initWstETH); + await wsteth.approve(primaryMarketRouter.address, initWstETH); + await primaryMarketRouter.create(owner.address, false, initWstETH, 0, 0); + await primaryMarket.split(owner.address, initQ, 0); + await fund.trancheApprove(TRANCHE_B, swapRouter.address, LP_B, 0); + await steth.mint(owner.address, LP_STETH); + await steth.approve(wsteth.address, LP_STETH); + await wsteth.wrap(LP_STETH); + await wsteth.approve(swapRouter.address, LP_STETH); + await swapRouter.addLiquidity( + await fund.tokenShare(TRANCHE_B), + wsteth.address, + LP_B, + LP_STETH, + 0, + 0, + startDay + ); + await wsteth.update(INIT_PRICE); + + return { + wallets: { user1, user2, owner }, + steth, + wsteth, + fund: fund.connect(user1), + stableSwap: stableSwap.connect(user1), + primaryMarketRouter: primaryMarketRouter.connect(user1), + flashSwapRouter: flashSwapRouter.connect(user1), + }; + } + + before(function () { + currentFixture = deployFixture; + }); + + beforeEach(async function () { + fixtureData = await loadFixture(currentFixture); + user1 = fixtureData.wallets.user1; + user2 = fixtureData.wallets.user2; + owner = fixtureData.wallets.owner; + addr1 = user1.address; + addr2 = user2.address; + steth = fixtureData.steth; + wsteth = fixtureData.wsteth; + fund = fixtureData.fund; + stableSwap = fixtureData.stableSwap; + primaryMarketRouter = fixtureData.primaryMarketRouter; + flashSwapRouter = fixtureData.flashSwapRouter; + }); + + describe("buyR", function () { + const outR = parseEther("1.1"); + const inQ = outR.mul(UNIT).div(INIT_SPLIT_RATIO); + const outB = outR.mul(WEIGHT_B); + const totalQuoteAmount = inQ.mul(1); + let inWstETH: BigNumber; + + beforeEach(async function () { + const quoteAmount = await stableSwap.getQuoteOut(outB); + inWstETH = totalQuoteAmount.sub(quoteAmount); + }); + + it("Should transfer quote and ROOK tokens", async function () { + await expect( + flashSwapRouter.buyR( + fund.address, + false, + primaryMarketRouter.address, + USER_STETH, + addr2, + wsteth.address, + 0, + outR + ) + ) + .to.emit(flashSwapRouter, "SwapRook") + .withArgs(addr2, 0, inWstETH.add(1), outR, 0); + expect(await fund.trancheBalanceOf(TRANCHE_R, addr2)).to.equal(outR); + const spentWstETH = USER_STETH.sub(await wsteth.balanceOf(addr1)); + expect(spentWstETH).to.be.closeTo(inWstETH, inWstETH.div(10000)); + }); + + it("Should transfer unwrapped quote and ROOK tokens", async function () { + const inStETH = inWstETH.mul(INIT_PRICE).div(UNIT); + await expect( + flashSwapRouter.buyR( + fund.address, + true, + primaryMarketRouter.address, + USER_STETH, + addr2, + wsteth.address, + 0, + outR + ) + ) + .to.emit(flashSwapRouter, "SwapRook") + .withArgs(addr2, 0, inWstETH.add(1), outR, 0); + expect(await fund.trancheBalanceOf(TRANCHE_R, addr2)).to.equal(outR); + const spentStETH = USER_STETH.sub(await steth.balanceOf(addr1)); + expect(spentStETH).to.be.closeTo(inStETH, inStETH.div(10000)); + }); + + it("Should check maximum input", async function () { + await expect( + flashSwapRouter.buyR( + fund.address, + false, + primaryMarketRouter.address, + inWstETH.div(2), + addr2, + wsteth.address, + 0, + outR + ) + ).to.be.revertedWith("Excessive input"); + }); + }); + + describe("sellR", function () { + const inR = parseEther("1.1"); + const inB = inR.mul(WEIGHT_B); + const swappedQ = inR + .mul(UNIT) + .div(INIT_SPLIT_RATIO) + .mul(10000 - MERGE_FEE_BPS) + .div(10000) + .add(1); + const totalQuoteAmount = swappedQ + .mul(10000 - REDEMPTION_FEE_BPS) + .div(10000) + .add(1); + let outWstETH: BigNumber; + + beforeEach(async function () { + const quoteAmount = await stableSwap.getQuoteIn(inB); + outWstETH = totalQuoteAmount.sub(quoteAmount); + }); + + it("Should transfer quote and ROOK tokens", async function () { + await fund.connect(owner).trancheTransfer(TRANCHE_R, addr1, inR, 0); + await fund.trancheApprove(TRANCHE_R, flashSwapRouter.address, inR, 0); + await expect( + flashSwapRouter.sellR( + fund.address, + false, + primaryMarketRouter.address, + 0, + addr2, + wsteth.address, + 0, + inR + ) + ) + .to.emit(flashSwapRouter, "SwapRook") + .withArgs(addr2, inR, 0, 0, outWstETH.sub(1)); + expect(await fund.trancheBalanceOf(TRANCHE_R, addr1)).to.equal(0); + const diffWstETH = (await wsteth.balanceOf(addr2)).sub(USER_STETH); + expect(diffWstETH).to.be.closeTo(outWstETH, outWstETH.div(10000)); + }); + + it("Should transfer unwrapped quote and ROOK tokens", async function () { + const outStETH = outWstETH.mul(INIT_PRICE).div(UNIT); + await fund.connect(owner).trancheTransfer(TRANCHE_R, addr1, inR, 0); + await fund.trancheApprove(TRANCHE_R, flashSwapRouter.address, inR, 0); + await expect( + flashSwapRouter.sellR( + fund.address, + true, + primaryMarketRouter.address, + 0, + addr2, + wsteth.address, + 0, + inR + ) + ) + .to.emit(flashSwapRouter, "SwapRook") + .withArgs(addr2, inR, 0, 0, outWstETH.sub(1)); + expect(await fund.trancheBalanceOf(TRANCHE_R, addr1)).to.equal(0); + expect(await steth.balanceOf(addr2)).to.be.closeTo(outStETH, outStETH.div(10000)); + }); + + it("Should check minimum output", async function () { + await fund.connect(owner).trancheTransfer(TRANCHE_R, addr1, inR, 0); + await fund.trancheApprove(TRANCHE_R, flashSwapRouter.address, inR, 0); + await expect( + flashSwapRouter.sellR( + fund.address, + false, + primaryMarketRouter.address, + outWstETH.mul(2), + addr2, + wsteth.address, + 0, + inR + ) + ).to.be.revertedWith("Insufficient output"); + }); + }); +});