diff --git a/packages/router/contracts/adapters/SpectraAdapter.sol b/packages/router/contracts/adapters/SpectraAdapter.sol new file mode 100644 index 00000000..42a8bc1f --- /dev/null +++ b/packages/router/contracts/adapters/SpectraAdapter.sol @@ -0,0 +1,574 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.19; + +import '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; +import '@openzeppelin/contracts/access/Ownable2Step.sol'; +import '@openzeppelin/contracts/interfaces/IERC4626.sol'; + +import '../interfaces/IMarginlyRouter.sol'; +import '../interfaces/IMarginlyAdapter.sol'; +import './interfaces/ICurvePool.sol'; +import './interfaces/ISpectraErc4626Wrapper.sol'; +import './interfaces/ISpectraPrincipalToken.sol'; + +/// @title Adapter for Spectra finance pool (old curve pool) of two tokens IBT/PT +/// @dev Two cases supported: +/// 1) Spectra pool PT / sw-IBT, marginly pool PT / IBT. +/// Adapter will wrap/unwrap IBT to sw-IBT during swaps. +/// 2) Spectra pool PT / IBT, marginly pool PT / IBT +/// 3) Spectra pool PT / IBT, marginly pool PT / Underlying of IBT. +/// Adapter will wrap/unwrap IBT to Underlying. Only for IBT tokens that supports immediate withdraw +contract SpectraAdapter is IMarginlyAdapter, Ownable2Step { + using SafeERC20 for IERC20; + + error WrongPoolInput(); + error UnknownPair(); + + event NewPair(address indexed ptToken, address indexed ibToken, address curvePool); + + enum QuoteTokenKind { + Ibt, // IBT token compatible with ERC4626 + IbtNotCompatibleWithERC4626, // Quote token is IBT not compatible with ERC4626 + UnderlyingOfIbt // Quote token is Underlying asset of IBT token compatible with ERC4626 + } + + struct PoolInput { + /// @dev Address of Principal Token + address pt; + /// @dev Address of QuoteToken. Could be IBT or Underlying asset of IBT + address quoteToken; + /// @dev Address of spectra curve pool IBT or Spectra Wrapped IBT / PT + address pool; + } + + struct PoolData { + /// @dev Address of spectra curve pool IBT/PT-IBT or sw-IBT/PT-sw-IBT + address pool; + /// @dev Address of IBT (or sw-IBT) + address ibt; + /// @dev Address of pt token + address pt; + /// @dev True if curvePool.coins[0] is ibt, curvePool.coins[1] is pt + bool zeroIndexCoinIsIbt; + /// @dev Type of quote token + QuoteTokenKind quoteTokenKind; + } + + mapping(address => mapping(address => PoolData)) public getPoolData; + + constructor(PoolInput[] memory pools) { + _addPools(pools); + } + + /// @dev Returns type of quote token + /// @param coin Address of coin in Spectra pool + /// @param quoteToken Address of quote token in Adapter + function _getQuoteTokenKind(address coin, address quoteToken) private view returns (QuoteTokenKind) { + if (coin == quoteToken) { + return QuoteTokenKind.Ibt; + } else if (IERC4626(coin).asset() == quoteToken) { + return QuoteTokenKind.UnderlyingOfIbt; + } else if (ISpectraErc4626Wrapper(coin).vaultShare() == quoteToken) { + return QuoteTokenKind.IbtNotCompatibleWithERC4626; + } else { + revert WrongPoolInput(); + } + } + + /// @dev Add array of pools + /// @param pools Array of input pools + function _addPools(PoolInput[] memory pools) private { + PoolInput memory input; + uint256 length = pools.length; + for (uint256 i; i < length; ) { + input = pools[i]; + + address coin0 = ICurvePool(input.pool).coins(0); + address coin1 = ICurvePool(input.pool).coins(1); + + PoolData memory poolData; + poolData.pool = input.pool; + + if (coin1 == input.pt) { + // check quote token is IBT, underlying of ibt or wrapped ibt + poolData.quoteTokenKind = _getQuoteTokenKind(coin0, input.quoteToken); + poolData.zeroIndexCoinIsIbt = true; + poolData.ibt = coin0; + poolData.pt = coin1; + } else if (coin0 == input.pt) { + // check quote token is IBT, underlying of ibt or wrapped ibt + poolData.quoteTokenKind = _getQuoteTokenKind(coin1, input.quoteToken); + poolData.zeroIndexCoinIsIbt = false; + poolData.ibt = coin1; + poolData.pt = coin0; + } else { + revert WrongPoolInput(); + } + + getPoolData[input.pt][input.quoteToken] = poolData; + getPoolData[input.quoteToken][input.pt] = poolData; + + emit NewPair(input.pt, input.quoteToken, input.pool); + + unchecked { + ++i; + } + } + } + + /// @dev Returns pool data by pair of tokens + function _getPoolDataSafe(address tokenA, address tokenB) private view returns (PoolData memory poolData) { + poolData = getPoolData[tokenA][tokenB]; + if (poolData.pool == address(0)) revert UnknownPair(); + } + + function _swapExactInputPreMaturity( + PoolData memory poolData, + address recipientArg, + address tokenInArg, + uint256 amountInArg, + uint256 minAmountOut + ) private returns (uint256 amountOut) { + bool tokenInIsPt = tokenInArg == poolData.pt; + uint256 tokenInIndex = poolData.zeroIndexCoinIsIbt && tokenInIsPt ? 1 : 0; + + if (tokenInIsPt) { + // PT -> IBT + address recipient = recipientArg; + + if (poolData.quoteTokenKind != QuoteTokenKind.Ibt) { + // change recipient to address(this), it let make unwrap swIbt for Ibt after swap + recipient = address(this); + } + + amountOut = _curveSwapExactInput(poolData.pool, recipient, tokenInArg, tokenInIndex, amountInArg, minAmountOut); + + if (poolData.quoteTokenKind == QuoteTokenKind.IbtNotCompatibleWithERC4626) { + // sw-IBT unwrap to IBT + amountOut = ISpectraErc4626Wrapper(poolData.ibt).unwrap(amountOut, recipientArg, address(this)); + } else if (poolData.quoteTokenKind == QuoteTokenKind.UnderlyingOfIbt) { + // IBT redeem to underlying asset + amountOut = IERC4626(poolData.ibt).redeem(amountOut, recipientArg, address(this)); + } + } else { + // IBT -> PT + + uint256 amountIn = amountInArg; + + if (poolData.quoteTokenKind == QuoteTokenKind.IbtNotCompatibleWithERC4626) { + // wrap IBT to sw-IBT and change recipient to current address(this) + IERC20(tokenInArg).forceApprove(poolData.ibt, amountInArg); + amountIn = ISpectraErc4626Wrapper(poolData.ibt).wrap(amountInArg, address(this)); + // tokenIn is sw-IBT + } else if (poolData.quoteTokenKind == QuoteTokenKind.UnderlyingOfIbt) { + // wrap Underlying asset to IBT and change recipient to current address(this) + IERC20(tokenInArg).forceApprove(poolData.ibt, amountInArg); + amountIn = IERC4626(poolData.ibt).deposit(amountInArg, address(this)); + } + + // swap in curve IBT to PT + amountOut = _curveSwapExactInput(poolData.pool, recipientArg, poolData.ibt, tokenInIndex, amountIn, minAmountOut); + } + } + + function _swapExactInputPostMaturity( + PoolData memory poolData, + address recipient, + address tokenIn, + uint256 amountIn + ) private returns (uint256 amountOut) { + if (tokenIn != poolData.pt) { + // IBT to PT swap is not possible after maturity + revert NotSupported(); + } + + if (poolData.quoteTokenKind == QuoteTokenKind.IbtNotCompatibleWithERC4626) { + // convert PT to sw-IBT + uint256 swAmountOut = ISpectraPrincipalToken(poolData.pt).redeemForIBT(amountIn, address(this), address(this)); + // convert sw-IBT to IBT + amountOut = ISpectraErc4626Wrapper(poolData.ibt).unwrap(swAmountOut, recipient, address(this)); + } else if (poolData.quoteTokenKind == QuoteTokenKind.UnderlyingOfIbt) { + // convert PT to Underlying + amountOut = ISpectraPrincipalToken(poolData.pt).redeem(amountIn, recipient, address(this)); + } else { + amountOut = ISpectraPrincipalToken(poolData.pt).redeemForIBT(amountIn, recipient, address(this)); + } + } + + function _swapExactOutputPtToIbtToUnderlyingPreMaturity( + PoolData memory poolData, + address recipient, + uint256 quoteAmountOut, + uint256 maxPtAmountIn + ) private returns (uint256 ptAmountIn) { + // PT -> IBT -> underlying + + uint256 tokenInIndex = poolData.zeroIndexCoinIsIbt ? 1 : 0; + + // calculate amount of IBT for exact quoteAmountOut + uint256 ibtAmountOut = IERC4626(poolData.ibt).previewWithdraw(quoteAmountOut); + + // swap maxAmountPt to IBT + uint256 ibtActualAmountOut = _curveSwapExactInput( + poolData.pool, + address(this), + poolData.pt, + tokenInIndex, + maxPtAmountIn, + ibtAmountOut + ); + + // withdraw exact amount of Underlying asset from IBT + IERC4626(poolData.ibt).withdraw(quoteAmountOut, recipient, address(this)); + + if (ibtActualAmountOut == ibtAmountOut) { + return maxPtAmountIn; + } + + uint256 deltaIbtAmountOut = ibtActualAmountOut - ibtAmountOut; + + // swap and move excessive tokenIn directly to recipient. + // last arg minAmountOut is zero because we made worst allowed by user swap + // and an additional swap with whichever output only improves it, + // so the tx shouldn't be reverted + uint256 excessivePtAmountIn = _curveSwapExactInput( + poolData.pool, + recipient, + poolData.ibt, + 1 - tokenInIndex, + deltaIbtAmountOut, + 0 + ); + + ptAmountIn = maxPtAmountIn - excessivePtAmountIn; + } + + /// @dev Swap PT to exact amount of IBT. When IBT is SpectraWrapper + function _swapExactOutputPtToSwIbtToIbtPreMaturity( + PoolData memory poolData, + address recipient, + uint256 ibtAmountOut, + uint256 maxPtAmountIn + ) private returns (uint256 ptAmountIn) { + uint256 tokenInIndex = poolData.zeroIndexCoinIsIbt ? 1 : 0; + + // PT -> sw-IBT -> IBT + // convert ibAmountOut to swAmountOut + uint256 swAmountOut = ISpectraErc4626Wrapper(poolData.ibt).previewWrap(ibtAmountOut); + + // swap maxAmountPt to swIbt + uint256 swActualAmountOut = _curveSwapExactInput( + poolData.pool, + address(this), + poolData.pt, + tokenInIndex, + maxPtAmountIn, + swAmountOut + ); + + // unwrap swAmountOut to get exact ibtAmountOut + ISpectraErc4626Wrapper(poolData.ibt).unwrap(swAmountOut, recipient, address(this)); + + if (swActualAmountOut == swAmountOut) { + return maxPtAmountIn; + } + + uint256 deltaSwAmountOut = swActualAmountOut - swAmountOut; + + // swap and move excessive tokenIn directly to recipient. + // last arg minAmountOut is zero because we made worst allowed by user swap + // and an additional swap with whichever output only improves it, + // so the tx shouldn't be reverted + uint256 excessivePtAmountIn = _curveSwapExactInput( + poolData.pool, + recipient, + poolData.ibt, + 1 - tokenInIndex, + deltaSwAmountOut, + 0 + ); + + ptAmountIn = maxPtAmountIn - excessivePtAmountIn; + } + + function _swapExactOutputPtToIbtPreMaturity( + PoolData memory poolData, + address recipient, + address ibtOut, + uint256 ibtAmountOut, + uint256 maxPtAmountIn + ) private returns (uint256 ptAmountIn) { + uint256 tokenInIndex = poolData.zeroIndexCoinIsIbt ? 1 : 0; + + // PT -> IBT + // swap maxAmountPt to ibt + uint256 ibtActualAmountOut = _curveSwapExactInput( + poolData.pool, + address(this), + poolData.pt, + tokenInIndex, + maxPtAmountIn, + ibtAmountOut + ); + + if (ibtActualAmountOut < ibtAmountOut) { + revert TooMuchRequested(); + } + + IERC20(ibtOut).safeTransfer(recipient, ibtAmountOut); + + if (ibtActualAmountOut == ibtAmountOut) { + return maxPtAmountIn; + } + + // swap and move excessive tokenIn directly to recipient. + // last arg minAmountOut is zero because we made worst allowed by user swap + // and an additional swap with whichever output only improves it, + // so the tx shouldn't be reverted + uint256 excessivePtAmountIn = _curveSwapExactInput( + poolData.pool, + recipient, + poolData.ibt, + 1 - tokenInIndex, + ibtActualAmountOut - ibtAmountOut, + 0 + ); + + ptAmountIn = maxPtAmountIn - excessivePtAmountIn; + } + + /// @dev Swap IBT to exact amount of PT. Returns excessive input amount + function _swapExactOutputIbtToPt( + PoolData memory poolData, + address recipient, + bool sendExcessiveToRecipient, + uint256 ibtInMaximum, + uint256 amountPtOut + ) private returns (uint256 excessiveIbtAmount) { + uint256 tokenInIndex = poolData.zeroIndexCoinIsIbt ? 0 : 1; + // swap all IBT to PT + uint256 actualAmountPtOut = _curveSwapExactInput( + poolData.pool, + address(this), + poolData.ibt, + tokenInIndex, + ibtInMaximum, + amountPtOut + ); + + if (actualAmountPtOut < amountPtOut) { + revert TooMuchRequested(); + } + + // send exact amount of tokenOut to recipient + IERC20(poolData.pt).safeTransfer(recipient, amountPtOut); + + if (actualAmountPtOut == amountPtOut) { + return 0; // all input amount was used + } + + uint256 deltaAmountPtOut = actualAmountPtOut - amountPtOut; + // swap and move excessive PT amount back to IBT + excessiveIbtAmount = _curveSwapExactInput( + poolData.pool, + sendExcessiveToRecipient ? recipient : address(this), + poolData.pt, + 1 - tokenInIndex, + deltaAmountPtOut, + 0 + ); + } + + function _swapExactOutputUnderlyingToIbtToPtPreMaturity( + PoolData memory poolData, + address recipient, + address quoteTokenIn, + uint256 ptAmountOut, + uint256 maxUnderlyingAmountIn + ) private returns (uint256 underlyingAmountIn) { + // Underlying -> IBT -> PT + // Convert all Underlying to IBT + IERC20(quoteTokenIn).forceApprove(poolData.ibt, maxUnderlyingAmountIn); + uint256 ibtMaxAmountIn = IERC4626(poolData.ibt).deposit(maxUnderlyingAmountIn, address(this)); + + // swap all IBT to PT + uint256 excessiveIbtIn = _swapExactOutputIbtToPt(poolData, recipient, false, ibtMaxAmountIn, ptAmountOut); + if (excessiveIbtIn == 0) return maxUnderlyingAmountIn; + + // convert excessive IBT to Underlying and return to recipient + uint256 excessiveUnderlyingAmountIn = IERC4626(poolData.ibt).redeem(excessiveIbtIn, recipient, address(this)); + underlyingAmountIn = maxUnderlyingAmountIn - excessiveUnderlyingAmountIn; + } + + function _swapExactOutputIbtToSwIbtToPtPreMaturity( + PoolData memory poolData, + address recipient, + address ibtIn, + uint256 ptAmountOut, + uint256 maxIbtAmountIn + ) private returns (uint256 ibAmountIn) { + // IBT -> sw-IBT -> PT + // wrap ibt to swIbt + IERC20(ibtIn).forceApprove(poolData.ibt, maxIbtAmountIn); + uint256 maxSwAmountIn = ISpectraErc4626Wrapper(poolData.ibt).wrap(maxIbtAmountIn, address(this)); + + uint256 excessiveSwAmountIn = _swapExactOutputIbtToPt(poolData, recipient, false, maxSwAmountIn, ptAmountOut); + if (excessiveSwAmountIn == 0) return maxIbtAmountIn; + + // unwrap excessive sw into ib and transfer back to recipient + uint256 excessiveIbAmountIn = ISpectraErc4626Wrapper(poolData.ibt).unwrap( + excessiveSwAmountIn, + recipient, + address(this) + ); + ibAmountIn = maxIbtAmountIn - excessiveIbAmountIn; + } + + /// @dev Swap IBT to exact amount of PT + function _swapExactOutputIbtToPtPreMaturity( + PoolData memory poolData, + address recipient, + uint256 ptAmountOut, + uint256 maxIbtAmountIn + ) private returns (uint256 ibAmountIn) { + // IBT -> PT + uint256 excessiveIbtAmountIn = _swapExactOutputIbtToPt(poolData, recipient, true, maxIbtAmountIn, ptAmountOut); + if (excessiveIbtAmountIn == 0) return maxIbtAmountIn; + + ibAmountIn = maxIbtAmountIn - excessiveIbtAmountIn; + } + + function _swapExactOutputPostMaturity( + PoolData memory poolData, + address recipient, + address tokenOut, + uint256 amountOut, + uint256 maxAmountIn + ) private returns (uint256 amountIn) { + if (tokenOut == poolData.pt) { + // swap IBT to PT is not possible after maturity + revert NotSupported(); + } + + if (poolData.quoteTokenKind == QuoteTokenKind.IbtNotCompatibleWithERC4626) { + // PT -> sw-IBT -> IBT + // calc sw-IBT amount from amountOut + uint256 swAmountOut = ISpectraErc4626Wrapper(poolData.ibt).previewWrap(amountOut); + amountIn = ISpectraPrincipalToken(poolData.pt).withdrawIBT(swAmountOut, address(this), address(this)); + uint256 actualAmountOut = ISpectraErc4626Wrapper(poolData.ibt).unwrap(swAmountOut, address(this), address(this)); + if (actualAmountOut < amountOut) revert InsufficientAmount(); + + // actualAmountOut could be more than amountOut, but it's not possible to change it back to PT after maturity + // dust may be left on the contract + IERC20(tokenOut).safeTransfer(recipient, amountOut); + } else if (poolData.quoteTokenKind == QuoteTokenKind.UnderlyingOfIbt) { + // PT -> Underlying + amountIn = ISpectraPrincipalToken(poolData.pt).withdraw(amountOut, recipient, address(this)); + } else { + amountIn = ISpectraPrincipalToken(poolData.pt).withdrawIBT(amountOut, recipient, address(this)); + } + + if (maxAmountIn == amountIn) { + return amountIn; + } + // return rest of PT token back to recipient + IERC20(poolData.pt).safeTransfer(recipient, maxAmountIn - amountIn); + } + + function _curveSwapExactInput( + address poolAddress, + address recipient, + address tokenIn, + uint256 tokenInIndex, + uint256 amountIn, + uint256 minAmountOut + ) private returns (uint256 amountOut) { + SafeERC20.forceApprove(IERC20(tokenIn), poolAddress, amountIn); + + amountOut = ICurvePool(poolAddress).exchange( + tokenInIndex, + 1 - tokenInIndex, + amountIn, + minAmountOut, + false, + recipient + ); + } + + function _ptIsExpired(address pt) private view returns (bool) { + return ISpectraPrincipalToken(pt).maturity() < block.timestamp; + } + + function swapExactInput( + address recipient, + address tokenIn, + address tokenOut, + uint256 amountIn, + uint256 minAmountOut, + bytes calldata data + ) external returns (uint256 amountOut) { + PoolData memory poolData = _getPoolDataSafe(tokenIn, tokenOut); + + // move all input tokens from router to this adapter + IMarginlyRouter(msg.sender).adapterCallback(address(this), amountIn, data); + + if (_ptIsExpired(poolData.pt)) { + amountOut = _swapExactInputPostMaturity(poolData, recipient, tokenIn, amountIn); + } else { + amountOut = _swapExactInputPreMaturity(poolData, recipient, tokenIn, amountIn, minAmountOut); + } + + if (amountOut < minAmountOut) revert InsufficientAmount(); + } + + function swapExactOutput( + address recipient, + address tokenIn, + address tokenOut, + uint256 maxAmountIn, + uint256 amountOut, + bytes calldata data + ) external returns (uint256 amountIn) { + PoolData memory poolData = _getPoolDataSafe(tokenIn, tokenOut); + + IMarginlyRouter(msg.sender).adapterCallback(address(this), maxAmountIn, data); + + if (_ptIsExpired(poolData.pt)) { + amountIn = _swapExactOutputPostMaturity(poolData, recipient, tokenOut, amountOut, maxAmountIn); + } else { + if (tokenIn == poolData.pt) { + if (poolData.quoteTokenKind == QuoteTokenKind.IbtNotCompatibleWithERC4626) { + amountIn = _swapExactOutputPtToSwIbtToIbtPreMaturity(poolData, recipient, amountOut, maxAmountIn); + } else if (poolData.quoteTokenKind == QuoteTokenKind.UnderlyingOfIbt) { + amountIn = _swapExactOutputPtToIbtToUnderlyingPreMaturity(poolData, recipient, amountOut, maxAmountIn); + } else { + amountIn = _swapExactOutputPtToIbtPreMaturity(poolData, recipient, tokenOut, amountOut, maxAmountIn); + } + } else { + if (poolData.quoteTokenKind == QuoteTokenKind.IbtNotCompatibleWithERC4626) { + amountIn = _swapExactOutputIbtToSwIbtToPtPreMaturity(poolData, recipient, tokenIn, amountOut, maxAmountIn); + } else if (poolData.quoteTokenKind == QuoteTokenKind.UnderlyingOfIbt) { + amountIn = _swapExactOutputUnderlyingToIbtToPtPreMaturity( + poolData, + recipient, + tokenIn, + amountOut, + maxAmountIn + ); + } else { + amountIn = _swapExactOutputIbtToPtPreMaturity(poolData, recipient, amountOut, maxAmountIn); + } + } + } + } + + function addPools(PoolInput[] calldata poolsData) external onlyOwner { + _addPools(poolsData); + } + + /// @dev During swap Pt to exact SW after maturity a little amount of sw-ibt might stay at the adapter contract + function sweepDust(address token, address recipient) external onlyOwner { + uint256 dust = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransfer(recipient, dust); + } +} diff --git a/packages/router/contracts/adapters/interfaces/ICurvePool.sol b/packages/router/contracts/adapters/interfaces/ICurvePool.sol index af434161..f0245379 100644 --- a/packages/router/contracts/adapters/interfaces/ICurvePool.sol +++ b/packages/router/contracts/adapters/interfaces/ICurvePool.sol @@ -43,4 +43,21 @@ interface ICurvePool { function price_oracle() external view returns (uint256); function last_price() external view returns (uint256); + + /* + @dev some pools have last_prices method, example https://etherscan.io/address/0xb09fc8bbdcc8dc9d8b3775132c52fcebf1c7dbb3#readContract + */ + function last_prices() external view returns (uint256); + + /* + @dev Spectra version of curve pool + */ + function exchange( + uint256 i, + uint256 j, + uint256 dx, + uint256 min_dy, + bool use_eth, + address receiver + ) external returns (uint256); } diff --git a/packages/router/contracts/adapters/interfaces/ISpectraErc4626Wrapper.sol b/packages/router/contracts/adapters/interfaces/ISpectraErc4626Wrapper.sol new file mode 100644 index 00000000..998fe027 --- /dev/null +++ b/packages/router/contracts/adapters/interfaces/ISpectraErc4626Wrapper.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.19; + +interface ISpectraErc4626Wrapper { + /// @dev Returns the address of the wrapped vault share. + function vaultShare() external view returns (address); + + /// @dev Allows the owner to deposit vault shares into the wrapper. + /// @param vaultShares The amount of vault shares to deposit. + /// @param receiver The address to receive the wrapper shares. + /// @return The amount of minted wrapper shares. + function wrap(uint256 vaultShares, address receiver) external returns (uint256); + + /// @dev Allows the owner to deposit vault shares into the wrapper, with support for slippage protection. + /// @param vaultShares The amount of vault shares to deposit. + /// @param receiver The address to receive the wrapper shares. + /// @param minShares The minimum allowed wrapper shares from this deposit. + /// @return The amount of minted wrapper shares. + function wrap(uint256 vaultShares, address receiver, uint256 minShares) external returns (uint256); + + /// @dev Allows the owner to withdraw vault shares from the wrapper. + /// @param shares The amount of wrapper shares to redeem. + /// @param receiver The address to receive the vault shares. + /// @param owner The address of the owner of the wrapper shares. + /// @return The amount of withdrawn vault shares. + function unwrap(uint256 shares, address receiver, address owner) external returns (uint256); + + /// @dev Allows the owner to withdraw vault shares from the wrapper, with support for slippage protection. + /// @param shares The amount of wrapper shares to redeem. + /// @param receiver The address to receive the vault shares. + /// @param owner The address of the owner of the wrapper shares. + /// @param minVaultShares The minimum vault shares that should be returned. + /// @return The amount of withdrawn vault shares. + function unwrap(uint256 shares, address receiver, address owner, uint256 minVaultShares) external returns (uint256); + + /// @dev Allows to preview the amount of minted wrapper shares for a given amount of deposited vault shares. + /// @param vaultShares The amount of vault shares to deposit. + /// @return The amount of minted vault shares. + function previewWrap(uint256 vaultShares) external view returns (uint256); + + /// @dev Allows to preview the amount of withdrawn vault shares for a given amount of redeemed wrapper shares. + /// @param shares The amount of wrapper shares to redeem. + /// @return The amount of withdrawn vault shares. + function previewUnwrap(uint256 shares) external view returns (uint256); +} diff --git a/packages/router/contracts/adapters/interfaces/ISpectraPrincipalToken.sol b/packages/router/contracts/adapters/interfaces/ISpectraPrincipalToken.sol new file mode 100644 index 00000000..e57c771a --- /dev/null +++ b/packages/router/contracts/adapters/interfaces/ISpectraPrincipalToken.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.19; + +interface ISpectraPrincipalToken { + /** + * @notice Returns the unix timestamp (uint256) at which the PT contract expires + * @return The unix timestamp (uint256) when PTs become redeemable + */ + function maturity() external view returns (uint256); + + /** + * @notice Burns owner's shares (PTs and YTs before expiry, PTs after expiry) + * and sends IBTs to receiver + * @param shares The amount of shares to burn + * @param receiver The address that will receive the IBTs + * @param owner The owner of the shares + * @return ibts The actual amount of IBT received for burning the shares + */ + function redeemForIBT(uint256 shares, address receiver, address owner) external returns (uint256 ibts); + + /** + * @notice Burns owner's shares (PTs and YTs before expiry, PTs after expiry) + * and sends IBTs to receiver + * @param shares The amount of shares to burn + * @param receiver The address that will receive the IBTs + * @param owner The owner of the shares + * @param minIbts The minimum IBTs that should be returned to user + * @return ibts The actual amount of IBT received for burning the shares + */ + function redeemForIBT( + uint256 shares, + address receiver, + address owner, + uint256 minIbts + ) external returns (uint256 ibts); + + /** + * @notice Burns owner's shares (before expiry : PTs and YTs) and sends IBTs to receiver + * @param ibts The amount of IBT to be received + * @param receiver The address that will receive the IBTs + * @param owner The owner of the shares (PTs and YTs) + * @return shares The actual amount of shares burnt for receiving the IBTs + */ + function withdrawIBT(uint256 ibts, address receiver, address owner) external returns (uint256 shares); + + /** + * @notice Burns owner's shares (before expiry : PTs and YTs) and sends IBTs to receiver + * @param ibts The amount of IBT to be received + * @param receiver The address that will receive the IBTs + * @param owner The owner of the shares (PTs and YTs) + * @param maxShares The maximum shares allowed to be burnt + * @return shares The actual amount of shares burnt for receiving the IBTs + */ + function withdrawIBT( + uint256 ibts, + address receiver, + address owner, + uint256 maxShares + ) external returns (uint256 shares); + + /** + * @notice Burns owner's shares (PTs and YTs before expiry, PTs after expiry) + * and sends assets to receiver + * @param shares The amount of shares to burn + * @param receiver The address that will receive the assets + * @param owner The owner of the shares + * @return assets The actual amount of assets received for burning the shares + */ + function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets); + + /** + * @notice Burns owner's shares (before expiry : PTs and YTs) and sends assets to receiver + * @param assets The amount of assets to be received + * @param receiver The address that will receive the assets + * @param owner The owner of the shares (PTs and YTs) + * @return shares The actual amount of shares burnt for receiving the assets + */ + function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares); +} diff --git a/packages/router/contracts/test/CurveEMATest/TestStableSwap2EMAOraclePool.sol b/packages/router/contracts/test/CurveEMATest/TestStableSwap2EMAOraclePool.sol index 363ff812..b4caf4ae 100644 --- a/packages/router/contracts/test/CurveEMATest/TestStableSwap2EMAOraclePool.sol +++ b/packages/router/contracts/test/CurveEMATest/TestStableSwap2EMAOraclePool.sol @@ -31,6 +31,22 @@ contract TestStableSwap2EMAOraclePool is ICurvePool { TransferHelper.safeTransfer(j == 0 ? token0 : token1, _receiver, dy); } + function exchange( + uint256 i, + uint256 j, + uint256 _dx, + uint256 _min_dy, + bool, + address _receiver + ) external returns (uint256 dy) { + dy = get_dy(int128(uint128(i)), int128(uint128(j)), _dx); + + if (dy < _min_dy) revert('dy < _min_dy'); + + TransferHelper.safeTransferFrom(i == 0 ? token0 : token1, msg.sender, address(this), _dx); + TransferHelper.safeTransfer(j == 0 ? token0 : token1, _receiver, dy); + } + function coins(uint256 i) external view returns (address) { if (i == 0) { return token0; @@ -64,4 +80,8 @@ contract TestStableSwap2EMAOraclePool is ICurvePool { function last_price() external view returns (uint256) { return price; } + + function last_prices() external view returns (uint256) { + return price; + } } diff --git a/packages/router/test/int/SpectraAdapter.eth.spec.ts b/packages/router/test/int/SpectraAdapter.eth.spec.ts new file mode 100644 index 00000000..dd9ae371 --- /dev/null +++ b/packages/router/test/int/SpectraAdapter.eth.spec.ts @@ -0,0 +1,635 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import { + ERC20, + MarginlyRouter, + MarginlyRouter__factory, + SpectraAdapter, + SpectraAdapter__factory, +} from '../../typechain-types'; +import { + constructSwap, + delay, + Dex, + resetFork, + showBalance, + showBalanceDelta, + showGasUsage, + SWAP_ONE, +} from '../shared/utils'; +import { EthAddress } from '@marginly/common'; +import { parseUnits } from 'ethers/lib/utils'; +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { EthereumMainnetERC20BalanceOfSlot, setTokenBalance } from '../shared/tokens'; +import { BigNumber } from 'ethers'; + +const swapCallData = constructSwap([Dex.Spectra], [SWAP_ONE]); + +interface TokenInfo { + address: string; + symbol: string; + balanceSlot: EthereumMainnetERC20BalanceOfSlot; + initialBalance: BigNumber; +} + +// For testing case when somebody make direct IBT transfer to sw-IBT and change rate IBT/sw-IBT +interface SWToken { + address: string; + symbol: string; + ibtTransferAmount: BigNumber; +} + +interface TestCase { + forkNumber: number; + + spectraPool: string; + + ptToken: TokenInfo; + quoteToken: TokenInfo; + swIbt?: SWToken; + + timeToMaturity: number; + preMaturity: { + swapExactIbtToPt: { + ibtIn: BigNumber; + minPtOut: BigNumber; + }; + swapExactPtToIbt: { + ptIn: BigNumber; + minIbtOut: BigNumber; + }; + swapIbtToExactPt: { + maxIbtIn: BigNumber; + ptOut: BigNumber; + }; + swapPtToExactIbt: { + maxPtIn: BigNumber; + ibtOut: BigNumber; + }; + }; + postMaturity: { + swapPtToExactIbt: { + maxPtIn: BigNumber; + ibtOut: BigNumber; + }; + swapExactPtToIbt: { + ptIn: BigNumber; + minIbtOut: BigNumber; + }; + }; +} + +const inwstETHs_TestCase: TestCase = { + forkNumber: 21714750, + + spectraPool: '0xe119bad8a35b999f65b1e5fd48c626c327daa16b', + ptToken: { + address: '0x4ae0154f83427a5864e5de6513a47dac9e5d5a69', + symbol: 'pt-sw-inwstETHs', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.PTSWINWSTETHS, + initialBalance: parseUnits('1000', 18), + }, + + quoteToken: { + address: '0x8e0789d39db454dbe9f4a77acef6dc7c69f6d552', + symbol: 'inwstETHs', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.INWSTETHS, + initialBalance: parseUnits('2000', 18), + }, + + // swIbt: { + // address: '0xd89fc47aacbb31e2bf23ec599f593a4876d8c18c', + // symbol: 'sw-inwstETHs', + // ibtTransferAmount: parseUnits('500', 18), + // }, + + timeToMaturity: 180 * 24 * 60 * 60, // 180 days + + // swap params + preMaturity: { + swapExactIbtToPt: { + ibtIn: parseUnits('1.5', 18), + minPtOut: parseUnits('1.0', 18), + }, + swapIbtToExactPt: { + maxIbtIn: parseUnits('1.0', 18), + ptOut: parseUnits('1.0', 18), + }, + swapExactPtToIbt: { + ptIn: parseUnits('10.75', 18), + minIbtOut: parseUnits('10.0', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('10.75', 18), + ibtOut: parseUnits('10.2', 18), + }, + }, + postMaturity: { + swapExactPtToIbt: { + ptIn: parseUnits('15.576', 18), + minIbtOut: parseUnits('15.0', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('20', 18), + ibtOut: parseUnits('18.87', 18), + }, + }, +}; + +const USR_TestCase: TestCase = { + forkNumber: 21714750, + + spectraPool: '0x0d89f4583a6b5eceb76551d573ad49cd435f6064', + ptToken: { + address: '0xd0097149aa4cc0d0e1fc99b8bd73fc17dc32c1e9', + symbol: 'pt-wstUSR', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.PTSWINWSTETHS, + initialBalance: parseUnits('10000', 18), + }, + + quoteToken: { + address: '0x66a1e37c9b0eaddca17d3662d6c05f4decf3e110', + symbol: 'USR', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.WSTUSR, + initialBalance: parseUnits('10000', 18), + }, + + timeToMaturity: 180 * 24 * 60 * 60, // 180 days + + // swap params + preMaturity: { + swapExactIbtToPt: { + ibtIn: parseUnits('500', 18), + minPtOut: parseUnits('500', 18), + }, + swapExactPtToIbt: { + ptIn: parseUnits('745.34', 18), + minIbtOut: parseUnits('650', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('15.75', 18), + ibtOut: parseUnits('10.2', 18), + }, + swapIbtToExactPt: { + maxIbtIn: parseUnits('125', 18), + ptOut: parseUnits('100', 18), + }, + }, + postMaturity: { + swapExactPtToIbt: { + ptIn: parseUnits('150.576', 18), + minIbtOut: parseUnits('120.0', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('600', 18), + ibtOut: parseUnits('500', 18), + }, + }, +}; + +const wstUSR_TestCase: TestCase = { + forkNumber: 21714750, + + spectraPool: '0x0d89f4583a6b5eceb76551d573ad49cd435f6064', + ptToken: { + address: '0xd0097149aa4cc0d0e1fc99b8bd73fc17dc32c1e9', + symbol: 'pt-wstUSR', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.PTSWINWSTETHS, + initialBalance: parseUnits('10000', 18), + }, + + quoteToken: { + address: '0x1202f5c7b4b9e47a1a484e8b270be34dbbc75055', + symbol: 'wstUSR', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.WSTUSR, + initialBalance: parseUnits('10000', 18), + }, + + timeToMaturity: 180 * 24 * 60 * 60, // 180 days + + // swap params + preMaturity: { + swapExactIbtToPt: { + ibtIn: parseUnits('500', 18), + minPtOut: parseUnits('500', 18), + }, + swapExactPtToIbt: { + ptIn: parseUnits('745.34', 18), + minIbtOut: parseUnits('650', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('15.75', 18), + ibtOut: parseUnits('10.2', 18), + }, + swapIbtToExactPt: { + maxIbtIn: parseUnits('125', 18), + ptOut: parseUnits('100', 18), + }, + }, + postMaturity: { + swapExactPtToIbt: { + ptIn: parseUnits('150.576', 18), + minIbtOut: parseUnits('120.0', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('600', 18), + ibtOut: parseUnits('500', 18), + }, + }, +}; + +const DOLA_TestCase: TestCase = { + forkNumber: 21714750, + + spectraPool: '0x69ba1b7dba7eb3b7a73f4e35fd04a27ad06c55fe', + ptToken: { + address: '0xf4ca2ce6eaa1b507570c4b340007f6266c7d5698', + symbol: 'pt-sDOLA', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.PTSWINWSTETHS, + initialBalance: parseUnits('10000', 18), + }, + + quoteToken: { + address: '0x865377367054516e17014ccded1e7d814edc9ce4', + symbol: 'DOLA', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.DOLA, + initialBalance: parseUnits('10000', 18), + }, + + timeToMaturity: 365 * 24 * 60 * 60, // 365 days + + // swap params + preMaturity: { + swapExactIbtToPt: { + ibtIn: parseUnits('500', 18), + minPtOut: parseUnits('500', 18), + }, + swapExactPtToIbt: { + ptIn: parseUnits('800.34', 18), + minIbtOut: parseUnits('650', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('15.75', 18), + ibtOut: parseUnits('10.2', 18), + }, + swapIbtToExactPt: { + maxIbtIn: parseUnits('600', 18), + ptOut: parseUnits('600', 18), + }, + }, + postMaturity: { + swapExactPtToIbt: { + ptIn: parseUnits('600', 18), + minIbtOut: parseUnits('500', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('600', 18), + ibtOut: parseUnits('500', 18), + }, + }, +}; + +const sDOLA_TestCase: TestCase = { + forkNumber: 21714750, + + spectraPool: '0x69ba1b7dba7eb3b7a73f4e35fd04a27ad06c55fe', + ptToken: { + address: '0xf4ca2ce6eaa1b507570c4b340007f6266c7d5698', + symbol: 'pt-sDOLA', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.PTSWINWSTETHS, + initialBalance: parseUnits('10000', 18), + }, + + quoteToken: { + address: '0xb45ad160634c528cc3d2926d9807104fa3157305', + symbol: 'sDOLA', + balanceSlot: EthereumMainnetERC20BalanceOfSlot.SDOLA, + initialBalance: parseUnits('10000', 18), + }, + + timeToMaturity: 365 * 24 * 60 * 60, // 365 days + + // swap params + preMaturity: { + swapExactIbtToPt: { + ibtIn: parseUnits('500', 18), + minPtOut: parseUnits('500', 18), + }, + swapExactPtToIbt: { + ptIn: parseUnits('800.34', 18), + minIbtOut: parseUnits('650', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('15.75', 18), + ibtOut: parseUnits('10.2', 18), + }, + swapIbtToExactPt: { + maxIbtIn: parseUnits('600', 18), + ptOut: parseUnits('600', 18), + }, + }, + postMaturity: { + swapExactPtToIbt: { + ptIn: parseUnits('600', 18), + minIbtOut: parseUnits('500', 18), + }, + swapPtToExactIbt: { + maxPtIn: parseUnits('600', 18), + ibtOut: parseUnits('500', 18), + }, + }, +}; + +const testCases = [ + USR_TestCase, // PT/Underlying case + DOLA_TestCase, //PT/Underlying case + wstUSR_TestCase, // PT-IBT/IBT case + sDOLA_TestCase, //PT-IBT/IBT case + inwstETHs_TestCase, // PT-sw/IBT case +]; + +async function initializeRouter(testCase: TestCase): Promise<{ + ptToken: ERC20; + ibtToken: ERC20; + router: MarginlyRouter; + spectraAdapter: SpectraAdapter; + owner: SignerWithAddress; + user: SignerWithAddress; +}> { + const [owner, user] = await ethers.getSigners(); + + const ptToken = await ethers.getContractAt('ERC20', testCase.ptToken.address); + const ibtToken = await ethers.getContractAt('ERC20', testCase.quoteToken.address); + const spectraPool = testCase.spectraPool; + + const poolInput: SpectraAdapter.PoolInputStruct = { + pt: ptToken.address, + quoteToken: ibtToken.address, + pool: spectraPool, + }; + + const spectraAdapter = await new SpectraAdapter__factory().connect(owner).deploy([poolInput]); + + const routerInput = { + dexIndex: Dex.Spectra, + adapter: spectraAdapter.address, + }; + const router = await new MarginlyRouter__factory().connect(owner).deploy([routerInput]); + + await setTokenBalance( + ibtToken.address, + testCase.quoteToken.balanceSlot, + EthAddress.parse(user.address), + testCase.quoteToken.initialBalance + ); + + await setTokenBalance( + ptToken.address, + testCase.ptToken.balanceSlot, + EthAddress.parse(user.address), + testCase.ptToken.initialBalance + ); + + expect(await ptToken.balanceOf(user.address)).to.be.eq( + testCase.ptToken.initialBalance, + `Wrong initial ${testCase.ptToken.symbol} balance` + ); + expect(await ibtToken.balanceOf(user.address)).to.be.eq( + testCase.quoteToken.initialBalance, + `Wrong initial ${testCase.quoteToken.symbol} balance` + ); + + if (testCase.swIbt) { + await setTokenBalance( + ibtToken.address, + testCase.quoteToken.balanceSlot, + EthAddress.parse(user.address), + testCase.quoteToken.initialBalance.add(testCase.swIbt.ibtTransferAmount) + ); + + await ibtToken.connect(user).transfer(testCase.swIbt.address, testCase.swIbt.ibtTransferAmount); + } + + return { + ptToken, + ibtToken, + router, + spectraAdapter, + owner, + user, + }; +} + +// Tests for running in ethereum mainnet fork +describe('SpectraAdapter', async () => { + for (const testCase of testCases) { + describe(`SpectraAdapter ${testCase.ptToken.symbol} - ${testCase.quoteToken.symbol}`, () => { + before(async () => { + await resetFork(testCase.forkNumber); + }); + + describe('Spectra swap pre maturity', () => { + let ptToken: ERC20; + let ibtToken: ERC20; + let router: MarginlyRouter; + let spectraAdapter: SpectraAdapter; + let user: SignerWithAddress; + + beforeEach(async () => { + ({ ptToken, ibtToken, router, spectraAdapter, user } = await initializeRouter(testCase)); + }); + + it(`${testCase.quoteToken.symbol} to ${testCase.ptToken.symbol} exact input`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'balance Before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'balance before:'); + + const ibtTokenAmount = testCase.preMaturity.swapExactIbtToPt.ibtIn; + await ibtToken.connect(user).approve(router.address, ibtTokenAmount); + + const minPTAmount = testCase.preMaturity.swapExactIbtToPt.minPtOut; + + const tx = await router + .connect(user) + .swapExactInput(swapCallData, ibtToken.address, ptToken.address, ibtTokenAmount, minPTAmount); + await showGasUsage(tx); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'pt balance After:'); + expect(ptBalanceAfter).to.be.greaterThan(ptBalanceBefore); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After:'); + expect(ibtBalanceBefore.sub(ibtBalanceAfter)).to.be.lessThanOrEqual(ibtTokenAmount); + + await showBalanceDelta(ptBalanceBefore, ptBalanceAfter, ptToken, 'PT balance delta:'); + await showBalanceDelta(ibtBalanceBefore, ibtBalanceAfter, ibtToken, 'IBT balance delta:'); + }); + + it(`${testCase.quoteToken.symbol} to ${testCase.ptToken.symbol} exact output`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'balance Before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'balance before:'); + + const exactPtOut = testCase.preMaturity.swapIbtToExactPt.ptOut; + const ibtMaxAmountIn = testCase.preMaturity.swapIbtToExactPt.maxIbtIn; + await ibtToken.connect(user).approve(router.address, ibtMaxAmountIn); + const tx = await router + .connect(user) + .swapExactOutput(swapCallData, ibtToken.address, ptToken.address, ibtMaxAmountIn, exactPtOut); + await showGasUsage(tx); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'pt balance After:'); + expect(ptBalanceAfter.sub(ptBalanceBefore)).to.be.eq(exactPtOut); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After: '); + expect(ibtBalanceBefore).to.be.greaterThan(ibtBalanceAfter); + + await showBalanceDelta(ptBalanceBefore, ptBalanceAfter, ptToken, 'PT balance delta:'); + await showBalanceDelta(ibtBalanceBefore, ibtBalanceAfter, ibtToken, 'IBT balance delta:'); + }); + + it(`${testCase.ptToken.symbol} to ${testCase.quoteToken.symbol} exact input`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'balance Before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'balance before:'); + + const ptIn = testCase.preMaturity.swapExactPtToIbt.ptIn; + const minIbtOut = testCase.preMaturity.swapExactPtToIbt.minIbtOut; + await ptToken.connect(user).approve(router.address, ptIn); + const tx = await router + .connect(user) + .swapExactInput(swapCallData, ptToken.address, ibtToken.address, ptIn, minIbtOut); + await showGasUsage(tx); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'pt BalanceAfter:'); + expect(ptBalanceBefore.sub(ptBalanceAfter)).to.be.eq(ptIn); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After:'); + expect(ibtBalanceAfter).to.be.greaterThan(ibtBalanceBefore); + + await showBalanceDelta(ptBalanceBefore, ptBalanceAfter, ptToken, 'PT balance delta:'); + await showBalanceDelta(ibtBalanceBefore, ibtBalanceAfter, ibtToken, 'IBT balance delta:'); + }); + + it(`${testCase.ptToken.symbol} to ${testCase.quoteToken.symbol} exact output`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'balance before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'balance before:'); + + const ibtMinOut = testCase.preMaturity.swapPtToExactIbt.ibtOut; + const maxPtIn = testCase.preMaturity.swapPtToExactIbt.maxPtIn; + await ptToken.connect(user).approve(router.address, maxPtIn); + const tx = await router + .connect(user) + .swapExactOutput(swapCallData, ptToken.address, ibtToken.address, maxPtIn, ibtMinOut); + await showGasUsage(tx); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'pt balanceAfter:'); + expect(ptBalanceBefore).to.be.greaterThan(ptBalanceAfter); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After:'); + expect(ibtBalanceAfter.sub(ibtBalanceBefore)).to.be.eq(ibtMinOut); + + await showBalanceDelta(ptBalanceBefore, ptBalanceAfter, ptToken, 'PT balance delta:'); + await showBalanceDelta(ibtBalanceBefore, ibtBalanceAfter, ibtToken, 'IBT balance delta:'); + }); + }); + + describe('Spectra swap post maturity', () => { + let ptToken: ERC20; + let ibtToken: ERC20; + let router: MarginlyRouter; + let spectraAdapter: SpectraAdapter; + let user: SignerWithAddress; + + beforeEach(async () => { + ({ ptToken, ibtToken, router, spectraAdapter, user } = await initializeRouter(testCase)); + + // move time and make after maturity + await ethers.provider.send('evm_increaseTime', [testCase.timeToMaturity]); + await ethers.provider.send('evm_mine', []); + }); + + it(`${testCase.quoteToken.symbol} to ${testCase.ptToken.symbol} exact input, forbidden`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'balance Before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'balance before:'); + + await ibtToken.connect(user).approve(router.address, ibtBalanceBefore); + const tx = router + .connect(user) + .swapExactInput(swapCallData, ibtToken.address, ptToken.address, ibtBalanceBefore, 0); + + await expect(tx).to.be.revertedWithCustomError(spectraAdapter, 'NotSupported'); + + console.log('This swap is forbidden after maturity'); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'pt Balance After:'); + expect(ptBalanceAfter).to.be.eq(ptBalanceBefore); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After:'); + expect(ibtBalanceAfter).to.be.eq(ibtBalanceBefore); + }); + + it(`${testCase.quoteToken.symbol} to ${testCase.ptToken.symbol} exact output, forbidden`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'balance Before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'balance before:'); + + await ibtToken.connect(user).approve(router.address, ibtBalanceBefore); + const tx = router + .connect(user) + .swapExactOutput(swapCallData, ibtToken.address, ptToken.address, ibtBalanceBefore, 1); + await expect(tx).to.be.revertedWithCustomError(spectraAdapter, 'NotSupported'); + + console.log('This swap is forbidden after maturity'); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'pt Balance After:'); + expect(ptBalanceAfter).to.be.eq(ptBalanceBefore); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After:'); + expect(ibtBalanceAfter).to.be.eq(ibtBalanceBefore); + }); + + it(`${testCase.ptToken.symbol} to ${testCase.quoteToken.symbol} exact input`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'balance Before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'balance before:'); + + const ptIn = testCase.postMaturity.swapExactPtToIbt.ptIn; + const minIbtOut = testCase.postMaturity.swapExactPtToIbt.minIbtOut; + await ptToken.connect(user).approve(router.address, ptIn); + const tx = await router + .connect(user) + .swapExactInput(swapCallData, ptToken.address, ibtToken.address, ptIn, minIbtOut); + await showGasUsage(tx); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'ptBalanceAfter:'); + expect(ptBalanceBefore.sub(ptBalanceAfter)).to.be.eq(ptIn); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After:'); + expect(ibtBalanceAfter).to.be.greaterThan(ibtBalanceBefore); + + await showBalanceDelta(ptBalanceBefore, ptBalanceAfter, ptToken, 'PT balance delta:'); + await showBalanceDelta(ibtBalanceBefore, ibtBalanceAfter, ibtToken, 'IBT balance delta:'); + }); + + it(`${testCase.ptToken.symbol} to ${testCase.quoteToken.symbol} exact output`, async () => { + const ptBalanceBefore = await showBalance(ptToken, user.address, 'pt balance Before:'); + const ibtBalanceBefore = await showBalance(ibtToken, user.address, 'ibt balance before:'); + + const ibtOut = testCase.postMaturity.swapPtToExactIbt.ibtOut; + await ptToken.connect(user).approve(router.address, ptBalanceBefore); + const maxPtIn = testCase.postMaturity.swapPtToExactIbt.maxPtIn; + const tx = await router + .connect(user) + .swapExactOutput(swapCallData, ptToken.address, ibtToken.address, maxPtIn, ibtOut); + await showGasUsage(tx); + + const ptBalanceAfter = await showBalance(ptToken, user.address, 'pt Balance After:'); + expect(ptBalanceBefore).to.be.greaterThan(ptBalanceAfter); + + const ibtBalanceAfter = await showBalance(ibtToken, user.address, 'ibt balance After:'); + expect(ibtBalanceAfter.sub(ibtBalanceBefore)).to.be.eq(ibtOut); + + await showBalanceDelta(ptBalanceBefore, ptBalanceAfter, ptToken, 'PT balance delta:'); + await showBalanceDelta(ibtBalanceBefore, ibtBalanceAfter, ibtToken, 'IBT balance delta:'); + }); + }); + }); + + await delay(3000); + } +}); diff --git a/packages/router/test/shared/tokens.ts b/packages/router/test/shared/tokens.ts index 2c8ae9e1..05d89d26 100644 --- a/packages/router/test/shared/tokens.ts +++ b/packages/router/test/shared/tokens.ts @@ -16,12 +16,19 @@ export enum ArbMainnetERC20BalanceOfSlot { // How to get: 1) decompile contract https://ethervm.io/decompile // 2) find balanceOf function and slot +// or find slot in blockexplorer statechange e.g. https://etherscan.io/tx/0xd3a83090d4e736aef85302e9835850d925c7d8da5180678fe440edc519966906#statechange export enum EthereumMainnetERC20BalanceOfSlot { WETH = '0000000000000000000000000000000000000000000000000000000000000003', WBTC = '0000000000000000000000000000000000000000000000000000000000000000', USDC = '0000000000000000000000000000000000000000000000000000000000000009', SUSDE = '0000000000000000000000000000000000000000000000000000000000000004', PTSUSDE = '0000000000000000000000000000000000000000000000000000000000000000', + PTSWINWSTETHS = '52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00', + INWSTETHS = '0000000000000000000000000000000000000000000000000000000000000065', + WSTUSR = '52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00', + SDOLA = '0000000000000000000000000000000000000000000000000000000000000003', + USR = '52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00', + DOLA = '0000000000000000000000000000000000000000000000000000000000000006', } function getAccountBalanceStorageSlot(account: EthAddress, tokenMappingSlot: string): string { diff --git a/packages/router/test/shared/utils.ts b/packages/router/test/shared/utils.ts index a2ec9109..92bd0f25 100644 --- a/packages/router/test/shared/utils.ts +++ b/packages/router/test/shared/utils.ts @@ -24,8 +24,9 @@ export const Dex = { Curve: 13, Pendle: 17, PendleMarket: 19, - PendleCurveRouter: 20, - PendleCurve: 21, + PendleCurveRouter: 30, + PendleCurve: 31, + Spectra: 32, }; export function constructSwap(dex: number[], ratios: number[]): BigNumber { @@ -43,16 +44,30 @@ export function constructSwap(dex: number[], ratios: number[]): BigNumber { export async function showGasUsage(tx: ContractTransaction) { const txReceipt = await tx.wait(); - console.log(`⛽ gas used ${txReceipt.gasUsed}`); + const warningLimit = 1_000_000; + console.log(`⛽ gas used ${txReceipt.gasUsed} ${txReceipt.gasUsed.gt(warningLimit) ? '!!! WARNING' : ''}`); } export async function showBalance(token: ERC20, account: string, startPhrase = ''): Promise { const [balance, symbol, decimals] = await Promise.all([token.balanceOf(account), token.symbol(), token.decimals()]); - console.log(`${startPhrase} ${formatUnits(balance, decimals)} ${symbol}`); + console.log(`${startPhrase.replace('$symbol', symbol)} ${formatUnits(balance, decimals)} ${symbol}`); return balance; } +export async function showBalanceDelta( + balanceBefore: BigNumber, + balanceAfter: BigNumber, + token: ERC20, + startPhrase = '' +) { + const [symbol, decimals] = await Promise.all([token.symbol(), token.decimals()]); + + console.log( + `${startPhrase.replace('$symbol', symbol)} ${formatUnits(balanceAfter.sub(balanceBefore), decimals)} ${symbol}` + ); +} + export async function resetFork(blockNumber?: number) { const hardhatConfig = (hre).config; const forkingBlockNumber = hardhatConfig.networks.hardhat.forking?.blockNumber; @@ -60,3 +75,7 @@ export async function resetFork(blockNumber?: number) { await reset(forkingUrl, blockNumber ?? forkingBlockNumber); } + +export function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +}