diff --git a/contracts/zero-ex/CHANGELOG.json b/contracts/zero-ex/CHANGELOG.json index 5f6b295c92..9a3c22e253 100644 --- a/contracts/zero-ex/CHANGELOG.json +++ b/contracts/zero-ex/CHANGELOG.json @@ -5,6 +5,10 @@ { "note": "Splits BridgeAdapter up by chain", "pr": 487 + }, + { + "note": "Add stETH wrap/unwrap support", + "pr": 476 } ] }, diff --git a/contracts/zero-ex/contracts/src/transformers/bridges/mixins/MixinLido.sol b/contracts/zero-ex/contracts/src/transformers/bridges/mixins/MixinLido.sol index b279089881..4b31e44518 100644 --- a/contracts/zero-ex/contracts/src/transformers/bridges/mixins/MixinLido.sol +++ b/contracts/zero-ex/contracts/src/transformers/bridges/mixins/MixinLido.sol @@ -26,7 +26,7 @@ import "@0x/contracts-erc20/contracts/src/v06/IEtherTokenV06.sol"; /// @dev Minimal interface for minting StETH -interface ILido { +interface IStETH { /// @dev Adds eth to the pool /// @param _referral optional address for referrals /// @return StETH Amount of shares generated @@ -37,6 +37,33 @@ interface ILido { function getPooledEthByShares(uint256 _sharesAmount) external view returns (uint256); } +/// @dev Minimal interface for wrapping/unwrapping stETH. +interface IWstETH { + + /** + * @notice Exchanges stETH to wstETH + * @param _stETHAmount amount of stETH to wrap in exchange for wstETH + * @dev Requirements: + * - `_stETHAmount` must be non-zero + * - msg.sender must approve at least `_stETHAmount` stETH to this + * contract. + * - msg.sender must have at least `_stETHAmount` of stETH. + * User should first approve _stETHAmount to the WstETH contract + * @return Amount of wstETH user receives after wrap + */ + function wrap(uint256 _stETHAmount) external returns (uint256); + + /** + * @notice Exchanges wstETH to stETH + * @param _wstETHAmount amount of wstETH to uwrap in exchange for stETH + * @dev Requirements: + * - `_wstETHAmount` must be non-zero + * - msg.sender must have at least `_wstETHAmount` wstETH. + * @return Amount of stETH user receives after unwrap + */ + function unwrap(uint256 _wstETHAmount) external returns (uint256); +} + contract MixinLido { using LibERC20TokenV06 for IERC20TokenV06; @@ -59,12 +86,43 @@ contract MixinLido { internal returns (uint256 boughtAmount) { - (ILido lido) = abi.decode(bridgeData, (ILido)); - if (address(sellToken) == address(WETH) && address(buyToken) == address(lido)) { + if (address(sellToken) == address(WETH)) { + return _tradeStETH(buyToken, sellAmount, bridgeData); + } + + return _tradeWstETH(sellToken, buyToken, sellAmount, bridgeData); + } + + function _tradeStETH( + IERC20TokenV06 buyToken, + uint256 sellAmount, + bytes memory bridgeData + ) private returns (uint256 boughtAmount) { + (IStETH stETH) = abi.decode(bridgeData, (IStETH)); + if (address(buyToken) == address(stETH)) { WETH.withdraw(sellAmount); - boughtAmount = lido.getPooledEthByShares(lido.submit{ value: sellAmount}(address(0))); - } else { - revert("MixinLido/UNSUPPORTED_TOKEN_PAIR"); + return stETH.getPooledEthByShares(stETH.submit{ value: sellAmount}(address(0))); } + + revert("MixinLido/UNSUPPORTED_TOKEN_PAIR"); + } + + function _tradeWstETH( + IERC20TokenV06 sellToken, + IERC20TokenV06 buyToken, + uint256 sellAmount, + bytes memory bridgeData + + ) private returns(uint256 boughtAmount){ + (IEtherTokenV06 stETH, IWstETH wstETH) = abi.decode(bridgeData, (IEtherTokenV06, IWstETH)); + if (address(sellToken) == address(stETH) && address(buyToken) == address(wstETH) ) { + sellToken.approveIfBelow(address(wstETH), sellAmount); + return wstETH.wrap(sellAmount); + } + if (address(sellToken) == address(wstETH) && address(buyToken) == address(stETH) ) { + return wstETH.unwrap(sellAmount); + } + + revert("MixinLido/UNSUPPORTED_TOKEN_PAIR"); } } diff --git a/packages/asset-swapper/CHANGELOG.json b/packages/asset-swapper/CHANGELOG.json index a884063ddb..2afaeecc0f 100644 --- a/packages/asset-swapper/CHANGELOG.json +++ b/packages/asset-swapper/CHANGELOG.json @@ -2,6 +2,10 @@ { "version": "16.61.0", "changes": [ + { + "note": "Add stETH wrap/unwrap support", + "pr": 476 + }, { "note": "Offboard/clean up Oasis, CoFix, and legacy Kyber", "pr": 482 diff --git a/packages/asset-swapper/contracts/src/LidoSampler.sol b/packages/asset-swapper/contracts/src/LidoSampler.sol index 70d3dc23c4..27e23f703a 100644 --- a/packages/asset-swapper/contracts/src/LidoSampler.sol +++ b/packages/asset-swapper/contracts/src/LidoSampler.sol @@ -22,10 +22,18 @@ pragma experimental ABIEncoderV2; import "./SamplerUtils.sol"; + +interface IWstETH { + function getWstETHByStETH(uint256 _stETHAmount) external view returns (uint256); + function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256); +} + + contract LidoSampler is SamplerUtils { struct LidoInfo { address stEthToken; address wethToken; + address wstEthToken; } /// @dev Sample sell quotes from Lido @@ -42,20 +50,17 @@ contract LidoSampler is SamplerUtils { uint256[] memory takerTokenAmounts ) public - pure + view returns (uint256[] memory) { _assertValidPair(makerToken, takerToken); - if (takerToken != lidoInfo.wethToken || makerToken != address(lidoInfo.stEthToken)) { - // Return 0 values if not selling WETH for stETH - uint256 numSamples = takerTokenAmounts.length; - uint256[] memory makerTokenAmounts = new uint256[](numSamples); - return makerTokenAmounts; + if (takerToken == lidoInfo.wethToken && makerToken == address(lidoInfo.stEthToken)) { + // Minting stETH is always 1:1 therefore we can just return the same amounts back. + return takerTokenAmounts; } - // Minting stETH is always 1:1 therefore we can just return the same amounts back - return takerTokenAmounts; + return _sampleSellsForWrapped(lidoInfo, takerToken, makerToken, takerTokenAmounts); } /// @dev Sample buy quotes from Lido. @@ -72,20 +77,43 @@ contract LidoSampler is SamplerUtils { uint256[] memory makerTokenAmounts ) public - pure + view returns (uint256[] memory) { - _assertValidPair(makerToken, takerToken); - - if (takerToken != lidoInfo.wethToken || makerToken != address(lidoInfo.stEthToken)) { - // Return 0 values if not buying stETH for WETH - uint256 numSamples = makerTokenAmounts.length; - uint256[] memory takerTokenAmounts = new uint256[](numSamples); - return takerTokenAmounts; + if (takerToken == lidoInfo.wethToken && makerToken == address(lidoInfo.stEthToken)) { + // Minting stETH is always 1:1 therefore we can just return the same amounts back. + return makerTokenAmounts; } - // Minting stETH is always 1:1 therefore we can just return the same amounts back - return makerTokenAmounts; + // Swap out `makerToken` and `takerToken` and re-use `_sampleSellsForWrapped`. + return _sampleSellsForWrapped(lidoInfo, makerToken, takerToken, makerTokenAmounts); } + function _sampleSellsForWrapped( + LidoInfo memory lidoInfo, + address takerToken, + address makerToken, + uint256[] memory takerTokenAmounts + ) private view returns (uint256[] memory) { + IWstETH wstETH = IWstETH(lidoInfo.wstEthToken); + uint256 numSamples = takerTokenAmounts.length; + uint256[] memory makerTokenAmounts = new uint256[](numSamples); + + if (takerToken == lidoInfo.stEthToken && makerToken == lidoInfo.wstEthToken) { + for (uint256 i = 0; i < numSamples; i++) { + makerTokenAmounts[i] = wstETH.getWstETHByStETH(takerTokenAmounts[i]); + } + return makerTokenAmounts; + } + + if (takerToken == lidoInfo.wstEthToken && makerToken == lidoInfo.stEthToken) { + for (uint256 i = 0; i < numSamples; i++) { + makerTokenAmounts[i] = wstETH.getStETHByWstETH(takerTokenAmounts[i]); + } + return makerTokenAmounts; + } + + // Returns 0 values. + return makerTokenAmounts; + } } diff --git a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts index 04d2eee31e..373bb919fa 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/constants.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/constants.ts @@ -22,6 +22,7 @@ import { GeistFillData, GetMarketOrdersOpts, isFinalUniswapV3FillData, + LidoFillData, LidoInfo, LiquidityProviderFillData, LiquidityProviderRegistry, @@ -448,6 +449,7 @@ export const MAINNET_TOKENS = { sEUR: '0xd71ecff9342a5ced620049e616c5035f1db98620', sETH: '0x5e74c9036fb86bd7ecdcb084a0673efc32ea31cb', stETH: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + wstETH: '0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0', LINK: '0x514910771af9ca656af840dff83e8264ecf986ca', MANA: '0x0f5d2fb29fb7d3cfee444a200298f468908cc942', KNC: '0xdefa4e8a7bcba345f687a2f1456f5edd9ce97202', @@ -931,6 +933,10 @@ export const DEFAULT_TOKEN_ADJACENCY_GRAPH_BY_CHAIN_ID = valueByChainId( export const LIDO_INFO_BY_CHAIN = valueByChainId( { [ChainId.Mainnet]: { - stEthToken: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + stEthToken: MAINNET_TOKENS.stETH, + wstEthToken: MAINNET_TOKENS.wstETH, wethToken: MAINNET_TOKENS.WETH, }, }, { + wstEthToken: NULL_ADDRESS, stEthToken: NULL_ADDRESS, wethToken: NULL_ADDRESS, }, @@ -2524,7 +2532,18 @@ export const DEFAULT_GAS_SCHEDULE: Required = { return gas; }, - [ERC20BridgeSource.Lido]: () => 226e3, + [ERC20BridgeSource.Lido]: (fillData?: FillData) => { + const lidoFillData = fillData as LidoFillData; + const wethAddress = NATIVE_FEE_TOKEN_BY_CHAIN_ID[ChainId.Mainnet]; + // WETH -> stETH + if (lidoFillData.takerToken === wethAddress) { + return 226e3; + } else if (lidoFillData.takerToken === lidoFillData.stEthTokenAddress) { + return 120e3; + } else { + return 95e3; + } + }, [ERC20BridgeSource.AaveV2]: (fillData?: FillData) => { const aaveFillData = fillData as AaveV2FillData; // NOTE: The Aave deposit method is more expensive than the withdraw diff --git a/packages/asset-swapper/src/utils/market_operation_utils/orders.ts b/packages/asset-swapper/src/utils/market_operation_utils/orders.ts index f23869a22b..5c6b72f3db 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/orders.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/orders.ts @@ -351,7 +351,7 @@ export function createBridgeDataForBridgeOrder(order: OptimizedMarketBridgeOrder break; case ERC20BridgeSource.Lido: const lidoFillData = (order as OptimizedMarketBridgeOrder).fillData; - bridgeData = encoder.encode([lidoFillData.stEthTokenAddress]); + bridgeData = encoder.encode([lidoFillData.stEthTokenAddress, lidoFillData.wstEthTokenAddress]); break; case ERC20BridgeSource.AaveV2: const aaveFillData = (order as OptimizedMarketBridgeOrder).fillData; @@ -568,7 +568,7 @@ export const BRIDGE_ENCODERS: { { name: 'path', type: 'bytes' }, ]), [ERC20BridgeSource.KyberDmm]: AbiEncoder.create('(address,address[],address[])'), - [ERC20BridgeSource.Lido]: AbiEncoder.create('(address)'), + [ERC20BridgeSource.Lido]: AbiEncoder.create('(address,address)'), [ERC20BridgeSource.AaveV2]: AbiEncoder.create('(address,address)'), [ERC20BridgeSource.Compound]: AbiEncoder.create('(address)'), [ERC20BridgeSource.Geist]: AbiEncoder.create('(address,address)'), diff --git a/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts b/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts index 243f994b94..d5c9edb0a8 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/sampler_operations.ts @@ -1106,8 +1106,10 @@ export class SamplerOperations { return new SamplerContractOperation({ source: ERC20BridgeSource.Lido, fillData: { + makerToken, takerToken, stEthTokenAddress: lidoInfo.stEthToken, + wstEthTokenAddress: lidoInfo.wstEthToken, }, contract: this._samplerContract, function: this._samplerContract.sampleSellsFromLido, @@ -1124,8 +1126,10 @@ export class SamplerOperations { return new SamplerContractOperation({ source: ERC20BridgeSource.Lido, fillData: { + makerToken, takerToken, stEthTokenAddress: lidoInfo.stEthToken, + wstEthTokenAddress: lidoInfo.wstEthToken, }, contract: this._samplerContract, function: this._samplerContract.sampleBuysFromLido, @@ -1603,16 +1607,10 @@ export class SamplerOperations { ].map(path => this.getUniswapV3SellQuotes(router, quoter, path, takerFillAmounts)); } case ERC20BridgeSource.Lido: { - const lidoInfo = LIDO_INFO_BY_CHAIN[this.chainId]; - if ( - lidoInfo.stEthToken === NULL_ADDRESS || - lidoInfo.wethToken === NULL_ADDRESS || - takerToken.toLowerCase() !== lidoInfo.wethToken.toLowerCase() || - makerToken.toLowerCase() !== lidoInfo.stEthToken.toLowerCase() - ) { + if (!this._isLidoSupported(takerToken, makerToken)) { return []; } - + const lidoInfo = LIDO_INFO_BY_CHAIN[this.chainId]; return this.getLidoSellQuotes(lidoInfo, makerToken, takerToken, takerFillAmounts); } case ERC20BridgeSource.AaveV2: { @@ -1685,6 +1683,24 @@ export class SamplerOperations { return allOps; } + private _isLidoSupported(takerTokenAddress: string, makerTokenAddress: string): boolean { + const lidoInfo = LIDO_INFO_BY_CHAIN[this.chainId]; + if (lidoInfo.wethToken === NULL_ADDRESS) { + return false; + } + const takerToken = takerTokenAddress.toLowerCase(); + const makerToken = makerTokenAddress.toLowerCase(); + const wethToken = lidoInfo.wethToken.toLowerCase(); + const stEthToken = lidoInfo.stEthToken.toLowerCase(); + const wstEthToken = lidoInfo.wstEthToken.toLowerCase(); + + if (takerToken === wethToken && makerToken === stEthToken) { + return true; + } + + return _.difference([stEthToken, wstEthToken], [takerToken, makerToken]).length === 0; + } + private _getBuyQuoteOperations( sources: ERC20BridgeSource[], makerToken: string, @@ -1924,17 +1940,10 @@ export class SamplerOperations { ].map(path => this.getUniswapV3BuyQuotes(router, quoter, path, makerFillAmounts)); } case ERC20BridgeSource.Lido: { - const lidoInfo = LIDO_INFO_BY_CHAIN[this.chainId]; - - if ( - lidoInfo.stEthToken === NULL_ADDRESS || - lidoInfo.wethToken === NULL_ADDRESS || - takerToken.toLowerCase() !== lidoInfo.wethToken.toLowerCase() || - makerToken.toLowerCase() !== lidoInfo.stEthToken.toLowerCase() - ) { + if (!this._isLidoSupported(takerToken, makerToken)) { return []; } - + const lidoInfo = LIDO_INFO_BY_CHAIN[this.chainId]; return this.getLidoBuyQuotes(lidoInfo, makerToken, takerToken, makerFillAmounts); } case ERC20BridgeSource.AaveV2: { diff --git a/packages/asset-swapper/src/utils/market_operation_utils/types.ts b/packages/asset-swapper/src/utils/market_operation_utils/types.ts index 141b30020a..60d026a981 100644 --- a/packages/asset-swapper/src/utils/market_operation_utils/types.ts +++ b/packages/asset-swapper/src/utils/market_operation_utils/types.ts @@ -167,6 +167,7 @@ export interface PsmInfo { export interface LidoInfo { stEthToken: string; wethToken: string; + wstEthToken: string; } /** @@ -325,7 +326,9 @@ export interface FinalUniswapV3FillData extends Omit { input: BigNumber; // Output fill amount (maker asset amount in a sell, taker asset amount in a buy). output: BigNumber; - // The output fill amount, ajdusted by fees. + // The output fill amount, adjusted by fees. adjustedOutput: BigNumber; // Fill that must precede this one. This enforces certain fills to be contiguous. parent?: Fill;