Add stETH wrap/unwrap support [TKR-377] (#476)

* Update MixinLido to support stETH wrapping/unwrapping

* Update LidoSampler and asset-swapper

* Re-use token address constants in LIDO_INFO_BY_CHAIN

* Update CHANGELOG.json

* Add stETH <-> wstETH to TokenAdjacencyGraph

* Change lido gas schedule code style

* Move allowance approval inside the wrap branch

* Refactor LidoSampler to reduce its bytecode size
This commit is contained in:
Kyu 2022-05-31 09:58:44 -07:00 committed by GitHub
parent cf8fc0ff8e
commit db76da58d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 171 additions and 46 deletions

View File

@ -5,6 +5,10 @@
{
"note": "Splits BridgeAdapter up by chain",
"pr": 487
},
{
"note": "Add stETH wrap/unwrap support",
"pr": 476
}
]
},

View File

@ -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");
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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<TokenAdj
builder
.add(MAINNET_TOKENS.OHMV2, MAINNET_TOKENS.BTRFLY)
.add(MAINNET_TOKENS.BTRFLY, MAINNET_TOKENS.OHMV2);
// Lido
builder
.add(MAINNET_TOKENS.stETH, MAINNET_TOKENS.wstETH)
.add(MAINNET_TOKENS.wstETH, MAINNET_TOKENS.stETH);
})
// Build
.build(),
@ -2125,11 +2131,13 @@ export const BEETHOVEN_X_VAULT_ADDRESS_BY_CHAIN = valueByChainId<string>(
export const LIDO_INFO_BY_CHAIN = valueByChainId<LidoInfo>(
{
[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<FeeSchedule> = {
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

View File

@ -351,7 +351,7 @@ export function createBridgeDataForBridgeOrder(order: OptimizedMarketBridgeOrder
break;
case ERC20BridgeSource.Lido:
const lidoFillData = (order as OptimizedMarketBridgeOrder<LidoFillData>).fillData;
bridgeData = encoder.encode([lidoFillData.stEthTokenAddress]);
bridgeData = encoder.encode([lidoFillData.stEthTokenAddress, lidoFillData.wstEthTokenAddress]);
break;
case ERC20BridgeSource.AaveV2:
const aaveFillData = (order as OptimizedMarketBridgeOrder<AaveV2FillData>).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)'),

View File

@ -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: {

View File

@ -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<UniswapV3FillData, 'pathAmo
export interface LidoFillData extends FillData {
stEthTokenAddress: string;
wstEthTokenAddress: string;
takerToken: string;
makerToken: string;
}
export interface AaveV2FillData extends FillData {
@ -385,7 +388,7 @@ export interface Fill<TFillData extends FillData = FillData> {
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;