From eab9a8b2691cca5cfb051f710f0633dc42eb9e07 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Fri, 14 Jun 2024 13:49:20 -0700 Subject: [PATCH] Implement 0x gasless swap plugin without approve --- src/index.ts | 2 + src/swap/defi/0x/0xGaslessSwap.ts | 125 +++++++++ src/swap/defi/0x/api.ts | 70 +++++ src/swap/defi/0x/apiTypes.ts | 423 +++++++++++++++++++++++++++++- src/swap/defi/0x/util.ts | 24 ++ 5 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 src/swap/defi/0x/0xGaslessSwap.ts diff --git a/src/index.ts b/src/index.ts index 85f860eb..e549045d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { makeGodexPlugin } from './swap/central/godex' import { makeLetsExchangePlugin } from './swap/central/letsexchange' import { makeSideshiftPlugin } from './swap/central/sideshift' import { makeSwapuzPlugin } from './swap/central/swapuz' +import { make0xGaslessSwap } from './swap/defi/0x/0xGaslessSwap' import { make0xSwap } from './swap/defi/0x/0xSwap' import { makeCosmosIbcPlugin } from './swap/defi/cosmosIbc' import { makeLifiPlugin } from './swap/defi/lifi' @@ -38,6 +39,7 @@ const plugins = { transfer: makeTransferPlugin, velodrome: makeVelodromePlugin, xrpdex, + zeroxgaslessswap: make0xGaslessSwap, zeroxswap: make0xSwap } diff --git a/src/swap/defi/0x/0xGaslessSwap.ts b/src/swap/defi/0x/0xGaslessSwap.ts new file mode 100644 index 00000000..fd592eda --- /dev/null +++ b/src/swap/defi/0x/0xGaslessSwap.ts @@ -0,0 +1,125 @@ +import { add } from 'biggystring' +import { + EdgeCorePluginFactory, + EdgeNetworkFee, + EdgeSwapApproveOptions, + EdgeSwapInfo, + EdgeSwapPlugin, + EdgeSwapQuote, + EdgeSwapResult +} from 'edge-core-js/types' + +import { ZeroXApi } from './api' +import { EXPIRATION_MS, NATIVE_TOKEN_ADDRESS } from './constants' +import { asInitOptions } from './types' +import { getCurrencyCode, getTokenAddress } from './util' + +const swapInfo: EdgeSwapInfo = { + displayName: '0x Gasless Swap', + pluginId: 'zeroxgaslessswap', + supportEmail: 'support@edge.app' +} + +export const make0xGaslessSwap: EdgeCorePluginFactory = ( + opts +): EdgeSwapPlugin => { + const { io } = opts + const initOptions = asInitOptions(opts.initOptions) + + const api = new ZeroXApi(io, initOptions.apiKey) + + return { + swapInfo, + fetchSwapQuote: async ( + swapRequest, + userSettings, + opts + ): Promise => { + // The fromWallet and toWallet must be of the same currency plugin + // type and therefore of the same network. + if ( + swapRequest.fromWallet.currencyInfo.pluginId !== + swapRequest.toWallet.currencyInfo.pluginId + ) { + throw new Error('Swap between different networks is not supported') + } + + const fromTokenAddress = getTokenAddress( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) + const fromCurrencyCode = getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) + const toTokenAddress = getTokenAddress( + swapRequest.toWallet, + swapRequest.toTokenId + ) + + if (swapRequest.quoteFor === 'max') { + throw new Error('Max quotes not supported') + } + + // From wallet address + const { + publicAddress: fromWalletAddress + } = await swapRequest.fromWallet.getReceiveAddress({ + tokenId: swapRequest.fromTokenId + }) + + // Amount request parameter/field name to use in the quote request + const amountField = + swapRequest.quoteFor === 'from' ? 'sellAmount' : 'buyAmount' + + // Get quote from ZeroXApi + const chainId = api.getChainIdFromPluginId( + swapRequest.fromWallet.currencyInfo.pluginId + ) + const apiSwapQuote = await api.gaslessSwapQuote(chainId, { + sellToken: fromTokenAddress ?? NATIVE_TOKEN_ADDRESS, + buyToken: toTokenAddress ?? NATIVE_TOKEN_ADDRESS, + takerAddress: fromWalletAddress, + [amountField]: swapRequest.nativeAmount + }) + + if (!apiSwapQuote.liquidityAvailable) + throw new Error('No liquidity available') + + const { gasFee, zeroExFee } = apiSwapQuote.fees + + if ( + gasFee.feeToken.toLocaleLowerCase() !== + fromTokenAddress?.toLocaleLowerCase() || + zeroExFee.feeToken.toLocaleLowerCase() !== + fromTokenAddress?.toLocaleLowerCase() + ) { + throw new Error( + 'Quoted fees must be in the same token as the from token in the swap request' + ) + } + + const networkFee: EdgeNetworkFee = { + currencyCode: fromCurrencyCode, + nativeAmount: add(gasFee.feeAmount, zeroExFee.feeAmount) + } + + return { + approve: async ( + opts?: EdgeSwapApproveOptions + ): Promise => { + throw new Error('Approve not yet implemented') + }, + close: async () => {}, + expirationDate: new Date(Date.now() + EXPIRATION_MS), + fromNativeAmount: apiSwapQuote.sellAmount, + isEstimate: false, + networkFee, + pluginId: swapInfo.pluginId, + request: swapRequest, + swapInfo: swapInfo, + toNativeAmount: apiSwapQuote.buyAmount + } + } + } +} diff --git a/src/swap/defi/0x/api.ts b/src/swap/defi/0x/api.ts index d16b6ae2..2c282efd 100644 --- a/src/swap/defi/0x/api.ts +++ b/src/swap/defi/0x/api.ts @@ -4,7 +4,11 @@ import { FetchResponse } from 'serverlet' import { asErrorResponse, + asGaslessSwapQuoteResponse, asSwapQuoteResponse, + ChainId, + GaslessSwapQuoteRequest, + GaslessSwapQuoteResponse, SwapQuoteRequest, SwapQuoteResponse } from './apiTypes' @@ -21,6 +25,32 @@ export class ZeroXApi { this.io = io } + /** + * Retrieves the ChainId based on the provided pluginId. + * + * @param pluginId The currency pluginId to retrieve the ChainId for. + * @returns The ChainId associated with the pluginId. + * @throws Error if the pluginId is not supported. + */ + getChainIdFromPluginId(pluginId: string): ChainId { + switch (pluginId) { + case 'arbitrum': + return ChainId.Arbitrum + case 'base': + return ChainId.Base + case 'ethereum': + return ChainId.Ethereum + case 'optimism': + return ChainId.Optimism + case 'polygon': + return ChainId.Polygon + default: + throw new Error( + `ZeroXApi: Unsupported ChainId for currency plugin: '${pluginId}'` + ) + } + } + /** * Get the 0x API endpoint based on the currency plugin ID. The endpoint is * the appropriate 0x API server for a particular network (Ethereum, Polygon, @@ -92,6 +122,46 @@ export class ZeroXApi { return responseData } + + /** + * Retrieves a gasless swap quote from the API. + * + * @param {ChainId} chainId - The ID of the chain (see {@link getChainIdFromPluginId}). + * @param {GaslessSwapQuoteRequest} request - The request object containing + * the necessary parameters for the swap quote. + * @returns {Promise} - A promise that resolves to + * the gasless swap quote response. + */ + async gaslessSwapQuote( + chainId: ChainId, + request: GaslessSwapQuoteRequest + ): Promise { + // Gasless API is only available on Ethereum network + const endpoint = this.getEndpointFromPluginId('ethereum') + + const queryParams = requestToParams(request) + const queryString = new URLSearchParams(queryParams).toString() + + const response = await this.io.fetch( + `${endpoint}/tx-relay/v1/swap/quote?${queryString}`, + { + headers: { + 'content-type': 'application/json', + '0x-api-key': this.apiKey, + '0x-chain-id': chainId.toString() + } + } + ) + + if (!response.ok) { + await handledErrorResponse(response) + } + + const responseText = await response.text() + const responseData = asGaslessSwapQuoteResponse(responseText) + + return responseData + } } async function handledErrorResponse(response: FetchResponse): Promise { diff --git a/src/swap/defi/0x/apiTypes.ts b/src/swap/defi/0x/apiTypes.ts index fdd45ced..0e77b57c 100644 --- a/src/swap/defi/0x/apiTypes.ts +++ b/src/swap/defi/0x/apiTypes.ts @@ -1,14 +1,25 @@ import { asArray, + asBoolean, asEither, asJSON, asNull, asNumber, asObject, + asOptional, asString, - asUnknown + asUnknown, + asValue } from 'cleaners' +export enum ChainId { + Ethereum = 1, + Polygon = 137, + Arbitrum = 42161, + Base = 8453, + Optimism = 10 +} + // ----------------------------------------------------------------------------- // Error Response // ----------------------------------------------------------------------------- @@ -25,6 +36,416 @@ export const asErrorResponse = asJSON( }) ) +// ----------------------------------------------------------------------------- +// Gasless API +// ----------------------------------------------------------------------------- + +export interface GaslessSwapQuoteRequest { + /** + * The contract address of the token being bought. Use + * `0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee` for native token. + */ + buyToken: string + + /** + * The contract address of token being sold. On Ethereum mainnet, it is + * restricted to the list of tokens [here](https://api.0x.org/tx-relay/v1/swap/supported-tokens) + * (Set `0x-chain-id` to `1`). + */ + sellToken: string + + /** + * The amount of `buyToken` to buy. Can only be present if `sellAmount` is not + * present. + */ + buyAmount?: string + + /** + * The amount of `sellToken` to sell. Can only be present if `buyAmount` is not + * present. + */ + sellAmount?: string + + /** + * The address of the taker. + */ + takerAddress: string + + /** + * [optional] Comma delimited string of the types of order the caller is + * willing to receive. + * + * Currently, `metatransaction_v2` and `otc` are supported and allowed. More + * details about order types are covered in [/quote documentation](https://0x.org/docs/tx-relay-api/api-references/get-tx-relay-v1-swap-quote). + * + * This is useful if the caller only wants to receive types the caller + * specifies. If not provided, it means the caller accepts any types whose + * default value is currently set to `metatransaction_v2` and `otc`. + */ + acceptedTypes?: string + + /** + * [optional] The maximum amount of slippage acceptable to the user; any + * slippage beyond that specified will cause the transaction to revert on + * chain. Default is 1% and minimal value allowed is 0.1%. The value of the + * field is on scale of 1. For example, setting `slippagePercentage` to set to + * `0.01` means 1% slippage allowed. + */ + slippagePercentage?: string + + /** + * [optional] The maximum amount of price impact acceptable to the user; Any + * price impact beyond that specified will cause the endpoint to return error + * if the endpoint is able to calculate the price impact. The value of the + * field is on scale of 1. For example, setting `priceImpactProtectionPercentage` + * to set to `0.01` means 1% price impact allowed. + * + * This is an opt-in feature, the default value of `1.0` will disable the + * feature. When it is set to 1.0 (100%) it means that every transaction is + * allowed to pass. + * + * Price impact calculation includes fees and could be unavailable. Read more + * about price impact at [0x documentation](https://docs.0x.org/0x-swap-api/advanced-topics/price-impact-protection). + */ + priceImpactProtectionPercentage?: string + + /** + * [optional] The type of integrator fee to charge. The allowed value is + * `volume`. + * + * Currently, the endpoint does not support integrator fees if the order type + * `otc` is chosen due to better pricing. Callers can opt-out of `otc` by + * explicitly passing in `acceptedTypes` query param without `otc`. `otc` order + * would, however, potentially improve the pricing the endpoint returned as + * there are more sources for liquidity. + */ + feeType?: string + + /** + * [optional] The address the integrator fee would be transferred to. This is + * the address you’d like to receive the fee. This must be present if `feeType` + * is provided. + */ + feeRecipient?: string + + /** + * [optional] If `feeType` is `volume`, then `feeSellTokenPercentage` must be + * provided. `feeSellTokenPercentage` is the percentage (on scale of 1) of + * `sellToken` integrator charges as fee. For example, setting it to `0.01` + * means 1% of the `sellToken` would be charged as fee for the integrator. + */ + feeSellTokenPercentage?: string + + /** + * [optional] A boolean that indicates whether or not to check for approval and + * potentially utilizes gasless approval feature. Allowed values `true` / + * `false`. Defaults to `false` if not provided. On a performance note, setting + * it to `true` requires more processing and computation than setting it to + * `false`. + * + * More details about gasless approval feature can be found [here](https://docs.0x.org/0x-swap-api/advanced-topics/gasless-approval). + */ + checkApproval?: boolean +} + +/** + * GaslessSwapQuoteResponse interface represents the response object returned + * by the API when making a gasless swap quote request. + */ +export type GaslessSwapQuoteResponse = + | GaslessSwapQuoteResponseLiquidity + | GaslessSwapQuoteResponseNoLiquidity + +interface GaslessSwapQuoteResponseNoLiquidity { + liquidityAvailable: false +} + +interface GaslessSwapQuoteResponseLiquidity { + /** + * Used to validate that liquidity is available from a given source. This + * would always be present. + */ + liquidityAvailable: true + + // --------------------------------------------------------------------------- + // The rest of the fields would only be present if `liquidityAvailable` is + // `true`. + // --------------------------------------------------------------------------- + + /** + * If `buyAmount` was specified in the request, this parameter provides the + * price of `buyToken`, denominated in `sellToken`, or vice-versa. + * + * Note: fees are baked in the price calculation. + */ + price: string + + /** + * Similar to `price` but with fees removed in the price calculation. This is + * the price as if no fee is charged. + */ + grossPrice: string + + /** + * The estimated change in the price of the specified asset that would be + * caused by the executed swap due to price impact. + * + * Note: If the API is not able to estimate price change, the field will be + * `null`. For `otc` order type, price impact is not available currently. + * More details about order types are covered in [/quote documentation](https://0x.org/docs/tx-relay-api/api-references/get-tx-relay-v1-swap-quote). + */ + estimatedPriceImpact: string + + /** + * Similar to `estimatedPriceImpact` but with fees removed. This is the + * `estimatedPriceImpact` as if no fee is charged. + */ + grossEstimatedPriceImpact: string + + /** + * The ERC20 token address of the token you want to receive in the quote. + */ + buyTokenAddress: string + + /** + * The amount of `buyToken` to buy with fees baked in. + */ + buyAmount: string + + /** + * Similar to `buyAmount` but with fees removed. This is the `buyAmount` as if + * no fee is charged. + */ + grossBuyAmount: string + + /** + * The ERC20 token address of the token you want to sell with the quote. + */ + sellTokenAddress: string + + /** + * The amount of `sellToken` to sell with fees baked in. + */ + sellAmount: string + + /** + * Similar to `sellAmount` but with fees removed. This is the `sellAmount` as + * if no fee is charged. + */ + grossSellAmount: string + + /** + * The target contract address for which the user needs to have an allowance + * in order to be able to complete the swap. + */ + allowanceTarget: string + + /** + * The underlying sources for the liquidity. The format will be: + * [{ name: string; proportion: string }] + * + * An example: `[{"name": "Uniswap_V2", "proportion": "0.87"}, {"name": "Balancer", "proportion": "0.13"}]` + */ + sources: Array<{ name: string; proportion: string }> + + /** + * [optional] Fees that would be charged. It can optionally contain + * `integratorFee`, `zeroExFee`, and `gasFee`. See details about each fee + * type below. + */ + fees: { + /** + * Related to `fees` param above. + * + * Integrator fee (in amount of `sellToken`) would be provided if `feeType` + * and the corresponding query params are provided in the request. + * + * - `feeType`: The type of the `integrator` fee. This is always the same as + * the `feeType` in the request. It can only be `volume` currently. + * - `feeToken`: The ERC20 token address to charge fee. This is always the + * same as `sellToken` in the request. + * - `feeAmount`: The amount of `feeToken` to be charged as integrator fee. + * - `billingType`: The method that integrator fee is transferred. It can + * only be `on-chain` which means integrator fee can only be transferred + * on-chain to `feeRecipient` query param provided. + * + * The endpoint currently does not support integrator fees if the order type + * `otc` is chosen due to better pricing. Callers can opt-out of `otc` by + * explicitly passing in `acceptedTypes` query param without `otc`. `otc` + * order would, however, potentially improve the pricing the endpoint + * returned as there are more sources for liquidity. + */ + integratorFee?: { + feeType: 'volume' + feeToken: string + feeAmount: string + billingType: 'on-chain' + } + + /** + * Related to `fees` param above. + * + * Fee that 0x charges: + * + * - `feeType`: `volume` or `integrator_share` which varies per integrator. + * `volume` means 0x would charge a certain percentage of the trade + * independently. `integrator_share` means 0x would change a certain + * percentage of what the integrator charges. + * - `feeToken`: The ERC20 token address to charge fee. The token could be + * either `sellToken` or `buyToken`. + * - `feeAmount`: The amount of `feeToken` to be charged as 0x fee. + * - `billingType`: The method that 0x fee is transferred. It can be either + * `on-chain`, `off-chain`, or `liquidity` which varies per integrator. + * `on-chain` means the fee would be charged on-chain. `off-chain` means + * the fee would be charged to the integrator via off-chain payment. + * `liquidity` means the fee would be charged off-chain but not to the + * integrator. + * + * Please reach out for more details on the `feeType` and `billingType`. + */ + zeroExFee: { + feeType: 'volume' | 'integrator_share' + feeToken: string + feeAmount: string + billingType: 'on-chain' | 'off-chain' | 'liquidity' + } + + /** + * Related to `fees`. See param above. + * + * Gas fee to compensate for the transaction submission performed by our + * relayers: + * + * - `feeType`: The value is always `gas`. + * - `feeToken`: The ERC20 token address to charge gas fee. The token could + * be either `sellToken` or `buyToken`. + * - `feeAmount`: The amount of `feeToken` to be charged as gas fee. + * - `billingType`: The method that gas compensation is transferred. It can + * be either `on-chain`, `off-chain`, or `liquidity` which has the same + * meaning as described above in `zeroExFee` section. + * + * Please reach out for more details on the `billingType`. + */ + gasFee: { + feeType: 'gas' + feeToken: string + feeAmount: string + billingType: 'on-chain' | 'off-chain' | 'liquidity' + } + } + + /** + * This is the "trade" object which contains the necessary information to + * process a trade. + * + * - `type`: `metatransaction_v2` or `otc` + * - `hash`: The hash for the trade according to EIP-712. Note that if you + * compute the hash from `eip712` field, it should match the value of this + * field. + * - `eip712`: Necessary data for EIP-712. + * + * Note: Please don't assume particular shapes of `trade.eip712.types`, + * `trade.eip712.domain`, `trade.eip712.primaryType`, and + * `trade.eip712.message` as they will change based on the `type` field and + * we would add more types in the future. + */ + trade?: { + type: string + hash: string + eip712: any + } + + /** + * This is the "approval" object which contains the necessary information to + * process a gasless approval, if requested via `checkApproval` and is + * available. You will only be able to initiate a gasless approval for the + * sell token if the response has both `isRequired` and `isGaslessAvailable` + * set to `true`. + * + * - `isRequired`: whether an approval is required for the trade + * - `isGaslessAvailable`: whether gasless approval is available for the sell + * token + * - `type`: `permit` or `executeMetaTransaction::approve` + * - `hash`: The hash for the approval according to EIP-712. Note that if you + * compute the hash from `eip712` field, it should match the value of this + * field. + * - `eip712`: Necessary data for EIP-712. + * + * Note: Please don't assume particular shapes of `approval.eip712.types`, + * `approval.eip712.domain`, `approval.eip712.primaryType`, and + * `approval.eip712.message` as they will change based on the `type` field. + * + * See [here](https://docs.0x.org/0x-swap-api/advanced-topics/gasless-approval) + * for more information about gasless approvals. + */ + approval?: { + isRequired: boolean + isGaslessAvailable: boolean + type: string + hash: string + eip712: any + } +} + +export const asGaslessSwapQuoteResponse = asJSON( + asEither( + asObject({ + liquidityAvailable: asValue(false) + }), + asObject({ + liquidityAvailable: asValue(true), + price: asString, + grossPrice: asString, + estimatedPriceImpact: asString, + grossEstimatedPriceImpact: asString, + buyTokenAddress: asString, + buyAmount: asString, + grossBuyAmount: asString, + sellTokenAddress: asString, + sellAmount: asString, + grossSellAmount: asString, + allowanceTarget: asString, + sources: asArray(asObject({ name: asString, proportion: asString })), + + fees: asObject({ + integratorFee: asOptional( + asObject({ + feeType: asValue('volume'), + feeToken: asString, + feeAmount: asString, + billingType: asValue('on-chain') + }) + ), + zeroExFee: asObject({ + feeType: asValue('volume', 'integrator_share'), + feeToken: asString, + feeAmount: asString, + billingType: asValue('on-chain', 'off-chain', 'liquidity') + }), + gasFee: asObject({ + feeType: asValue('gas'), + feeToken: asString, + feeAmount: asString, + billingType: asValue('on-chain', 'off-chain', 'liquidity') + }) + }), + + trade: asOptional( + asObject({ type: asString, hash: asString, eip712: asUnknown }) + ), + approval: asOptional( + asObject({ + isRequired: asBoolean, + isGaslessAvailable: asBoolean, + type: asString, + hash: asString, + eip712: asUnknown + }) + ) + }) + ) +) + // ----------------------------------------------------------------------------- // Swap API // ----------------------------------------------------------------------------- diff --git a/src/swap/defi/0x/util.ts b/src/swap/defi/0x/util.ts index 37a5c738..22b4cf8b 100644 --- a/src/swap/defi/0x/util.ts +++ b/src/swap/defi/0x/util.ts @@ -1,5 +1,29 @@ import { EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js/types' +/** + * Retrieves the currency code for a given token ID on a given currency wallet. + * + * @param wallet The EdgeCurrencyWallet object. + * @param tokenId The EdgeTokenId for the token. + * @returns The currency code associated with the tokenId. + * @throws Error if the token ID is not found in the wallet's currency configuration. + */ +export const getCurrencyCode = ( + wallet: EdgeCurrencyWallet, + tokenId: EdgeTokenId +): string => { + if (tokenId == null) { + return wallet.currencyInfo.currencyCode + } else { + if (wallet.currencyConfig.allTokens[tokenId] == null) { + throw new Error( + `getCurrencyCode: tokenId: '${tokenId}' not found for wallet pluginId: '${wallet.currencyInfo.pluginId}'` + ) + } + return wallet.currencyConfig.allTokens[tokenId].currencyCode + } +} + /** * Returns the token contract address for a given EdgeTokenId. *