From 3fbdd20593b40fab76dfb9c4394b9540dba9baf0 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Tue, 25 Jun 2024 16:08:21 -0700 Subject: [PATCH 1/4] Implement 0xgasless plugin without approve --- src/index.ts | 4 +- src/swap/defi/0x/0xGasless.ts | 118 +++++ src/swap/defi/0x/ZeroXApi.ts | 169 ++++++ src/swap/defi/0x/constants.ts | 3 + src/swap/defi/0x/types.ts | 5 + src/swap/defi/0x/util.ts | 45 ++ src/swap/defi/0x/zeroXApiTypes.ts | 840 ++++++++++++++++++++++++++++++ 7 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 src/swap/defi/0x/0xGasless.ts create mode 100644 src/swap/defi/0x/ZeroXApi.ts create mode 100644 src/swap/defi/0x/constants.ts create mode 100644 src/swap/defi/0x/types.ts create mode 100644 src/swap/defi/0x/util.ts create mode 100644 src/swap/defi/0x/zeroXApiTypes.ts diff --git a/src/index.ts b/src/index.ts index e9dc234d..e9f97930 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 { make0xGaslessPlugin } from './swap/defi/0x/0xGasless' import { makeCosmosIbcPlugin } from './swap/defi/cosmosIbc' import { makeLifiPlugin } from './swap/defi/lifi' import { makeRangoPlugin } from './swap/defi/rango' @@ -38,7 +39,8 @@ const plugins = { tombSwap: makeTombSwapPlugin, transfer: makeTransferPlugin, velodrome: makeVelodromePlugin, - xrpdex + xrpdex, + '0xgasless': make0xGaslessPlugin } declare global { diff --git a/src/swap/defi/0x/0xGasless.ts b/src/swap/defi/0x/0xGasless.ts new file mode 100644 index 00000000..4e79f795 --- /dev/null +++ b/src/swap/defi/0x/0xGasless.ts @@ -0,0 +1,118 @@ +import { add } from 'biggystring' +import { + EdgeCorePluginFactory, + EdgeNetworkFee, + EdgeSwapApproveOptions, + EdgeSwapInfo, + EdgeSwapQuote, + EdgeSwapResult +} from 'edge-core-js/types' + +import { EXPIRATION_MS, NATIVE_TOKEN_ADDRESS } from './constants' +import { asInitOptions } from './types' +import { getCurrencyCode, getTokenAddress } from './util' +import { ZeroXApi } from './ZeroXApi' + +const swapInfo: EdgeSwapInfo = { + displayName: '0x Gasless Swap', + pluginId: '0xgasless', + supportEmail: 'support@edge.app' +} + +export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { + const { io } = opts + const initOptions = asInitOptions(opts.initOptions) + + const api = new ZeroXApi(io, initOptions.apiKey) + + return { + swapInfo, + fetchSwapQuote: async (swapRequest): 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 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 fromCurrencyCode = getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) + 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/ZeroXApi.ts b/src/swap/defi/0x/ZeroXApi.ts new file mode 100644 index 00000000..8ae35d82 --- /dev/null +++ b/src/swap/defi/0x/ZeroXApi.ts @@ -0,0 +1,169 @@ +import { asMaybe } from 'cleaners' +import { EdgeIo } from 'edge-core-js' +import { FetchResponse } from 'serverlet' + +import { + asErrorResponse, + asGaslessSwapQuoteResponse, + ChainId, + GaslessSwapQuoteRequest, + GaslessSwapQuoteResponse, + SwapQuoteRequest +} from './zeroXApiTypes' + +/** + * Represents the ZeroXApi class that interacts with the 0x API. + */ +export class ZeroXApi { + apiKey: string + io: EdgeIo + + constructor(io: EdgeIo, apiKey: string) { + this.apiKey = apiKey + 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, + * etc). + * + * @param pluginId Currency plugin ID + * @returns The 0x API endpoint URL + * @throws Error if the pluginId is not supported. + */ + getEndpointFromPluginId(pluginId: string): string { + switch (pluginId) { + case 'arbitrum': + return 'https://arbitrum.api.0x.org' + case 'avalanche': + return 'https://avalanche.api.0x.org' + case 'binancesmartchain': + return 'https://bsc.api.0x.org' + case 'base': + return 'https://base.api.0x.org' + case 'celo': + return 'https://celo.api.0x.org' + case 'ethereum': + return 'https://api.0x.org' + case 'fantom': + return 'https://fantom.api.0x.org' + case 'optimism': + return 'https://optimism.api.0x.org' + case 'polygon': + return 'https://polygon.api.0x.org' + case 'sepolia': + return 'https://sepolia.api.0x.org' + default: + throw new Error( + `ZeroXApi: Unsupported endpoint for currency plugin: '${pluginId}'` + ) + } + } + + /** + * 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 { + const responseText = await response.text() + const errorResponse = asMaybe(asErrorResponse)(responseText) + + // If error response cleaner failed, then throw the raw response text + if (errorResponse == null) { + let truncatedText = responseText.slice(0, 500) // Truncate to 500 characters + if (truncatedText !== responseText) { + truncatedText += '...' + } + throw new Error(`0x API HTTP ${response.status} response: ${truncatedText}`) + } + + // Throw error with response code and reason included + const { code, reason } = errorResponse + throw new Error( + `0x API HTTP ${response.status} response: code=${code} reason=${reason}` + ) +} + +/** + * Removes undefined fields from a API request objects and returns a Record + * type object which can be parsed using `URLSearchParams`. + * + * @param request - The request object with partial fields. + * @returns The params object containing only string values. + */ +function requestToParams(request: SwapQuoteRequest): Record { + const result: Record = {} + for (const key in request) { + if (Object.hasOwnProperty.call(request, key)) { + const value = request[key as keyof SwapQuoteRequest] // Add index signature + if (value !== undefined) { + result[key] = value + } + } + } + return result +} diff --git a/src/swap/defi/0x/constants.ts b/src/swap/defi/0x/constants.ts new file mode 100644 index 00000000..0e6b9b1d --- /dev/null +++ b/src/swap/defi/0x/constants.ts @@ -0,0 +1,3 @@ +export const EXPIRATION_MS = 1000 * 60 +/** [The ERC-7528: ETH (Native Asset) Address Convention](https://eips.ethereum.org/EIPS/eip-7528) */ +export const NATIVE_TOKEN_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' diff --git a/src/swap/defi/0x/types.ts b/src/swap/defi/0x/types.ts new file mode 100644 index 00000000..64ebe8f6 --- /dev/null +++ b/src/swap/defi/0x/types.ts @@ -0,0 +1,5 @@ +import { asObject, asString } from 'cleaners' + +export const asInitOptions = asObject({ + apiKey: asString +}) diff --git a/src/swap/defi/0x/util.ts b/src/swap/defi/0x/util.ts new file mode 100644 index 00000000..22b4cf8b --- /dev/null +++ b/src/swap/defi/0x/util.ts @@ -0,0 +1,45 @@ +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. + * + * @param wallet wallet object to look up token address + * @param tokenId the EdgeTokenId of the token to look up + * @returns the contract address of the token, or null for native token (e.g. ETH) + */ +export const getTokenAddress = ( + wallet: EdgeCurrencyWallet, + tokenId: EdgeTokenId +): string | null => { + const edgeToken = + tokenId == null ? undefined : wallet.currencyConfig.allTokens[tokenId] + if (edgeToken == null) return null + const address = edgeToken.networkLocation?.contractAddress + if (address == null) + throw new Error('Missing contractAddress in EdgeToken networkLocation') + return address +} diff --git a/src/swap/defi/0x/zeroXApiTypes.ts b/src/swap/defi/0x/zeroXApiTypes.ts new file mode 100644 index 00000000..77fd58c1 --- /dev/null +++ b/src/swap/defi/0x/zeroXApiTypes.ts @@ -0,0 +1,840 @@ +import { + asArray, + asBoolean, + asEither, + asJSON, + asNull, + asNumber, + asObject, + asOptional, + asString, + asUnknown, + asValue +} from 'cleaners' + +export enum ChainId { + Ethereum = 1, + Polygon = 137, + Arbitrum = 42161, + Base = 8453, + Optimism = 10 +} + +// ----------------------------------------------------------------------------- +// Error Response +// ----------------------------------------------------------------------------- + +export interface ErrorResponse { + code: number + reason: string +} + +export const asErrorResponse = asJSON( + asObject({ + code: asNumber, + reason: asString + }) +) + +// ----------------------------------------------------------------------------- +// 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 | null + + /** + * Similar to `estimatedPriceImpact` but with fees removed. This is the + * `estimatedPriceImpact` as if no fee is charged. + */ + grossEstimatedPriceImpact: string | null + + /** + * 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: asEither(asString, asNull), + grossEstimatedPriceImpact: asEither(asString, asNull), + 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 +// ----------------------------------------------------------------------------- + +export interface SwapQuoteRequest { + /** + * The ERC20 token address of the token address you want to sell. + * + * Use address 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for native token + * (e.g. ETH). + **/ + sellToken: string + + /** + * The ERC20 token address of the token address you want to receive. + * + * Use address 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee for native token + * (e.g. ETH). + */ + buyToken: string + + /** + * The amount of sellToken (in sellToken base units) you want to send. + * + * Either sellAmount or buyAmount must be present in a request. + */ + sellAmount?: string + + /** + * The amount of buyToken(in buyToken base units) you want to receive. + * + * Either sellAmount or buyAmount must be present in a request. + */ + buyAmount?: string + + /** + * The maximum acceptable slippage of the buyToken amount if sellAmount is + * provided; The maximum acceptable slippage of the sellAmount amount if + * buyAmount is provided (e.g. 0.03 for 3% slippage allowed). + * + * The lowest possible value that can be set for this parameter is 0; in + * other words, no amount of slippage would be allowed. + * + * Default: 0.01 (1%) + */ + slippagePercentage?: string + + /** + * The target gas price (in wei) for the swap transaction. If the price is + * too low to achieve the quote, an error will be returned. + * + * Default: ethgasstation "fast" + */ + gasPrice?: string + + /** + * The address which will fill the quote. While optional, we highly recommend + * providing this parameter if possible so that the API can more accurately + * estimate the gas required for the swap transaction. + * + * This helps when validating the entire transaction for success, and catches + * revert issues. If the validation fails, a Revert Error will be returned in + * the response. The quote should be fillable if this address is provided. + * Also, make sure this address has enough token balance. Additionally, + * including the `takerAddress is required if you want to integrate RFQ + * liquidity. + */ + takerAddress?: string + + /** + * Liquidity sources (Uniswap, SushiSwap, 0x, Curve, etc) that will not be + * included in the provided quote. See here for a full list of sources. + * This parameter cannot be combined with includedSources. + * + * Example: excludedSources=Uniswap,SushiSwap,Curve + */ + excludedSources?: string + + /** + * Typically used to filter for RFQ liquidity without any other DEX orders + * which this is useful for testing your RFQ integration. To do so, set it + * to 0x. This parameter cannot be combined with excludedSources. + * + * includedSources=0x + */ + includedSources?: string + + /** + * Normally, whenever a takerAddress is provided, the API will validate the + * quote for the user. + * For more details, see "How does takerAddress help with catching issues?" + * + * When this parameter is set to true, that validation will be skipped. + * Also see Quote Validation here. For /quote , the default of + * skipValidation=false but can be overridden to true. + */ + skipValidation?: string + + /** + * The ETH address that should receive affiliate fees specified with + * buyTokenPercentageFee . Can be used combination with buyTokenPercentageFee + * to set a commission/trading fee when using the API. + * Learn more about how to setup a trading fee/commission fee/transaction + * fee here in the FAQs. + */ + feeRecipient?: string + + /** + * The percentage (denoted as a decimal between 0 - 1.0 where 1.0 represents + * 100%) of the buyAmount that should be attributed to feeRecipient as + * affiliate fees. Note that this requires that the feeRecipient parameter + * is also specified in the request. Learn more about how to setup a trading + * fee/commission fee/transaction fee here in the FAQs. + */ + buyTokenPercentageFee?: string + + /** + * The percentage (between 0 - 1.0) of allowed price impact. + * When priceImpactProtectionPercentage is set, estimatedPriceImpact is + * returned which estimates the change in the price of the specified asset + * that would be caused by the executed swap due to price impact. + * + * If the estimated price impact is above the percentage indicated, an error + * will be returned. For example, if PriceImpactProtectionPercentage=.15 + * (15%), any quote with a price impact higher than 15% will return an error. + * + * 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. + * + * Note: When we fail to calculate Price Impact we will return null and + * Price Impact Protection will be disabled See affects on + * estimatedPriceImpact in the Response fields. Read more about price impact + * protection and how to set it up here. + * + * Defaults: 100% + */ + priceImpactProtectionPercentage?: string + + /** + * The recipient address of any trade surplus fees. If specified, this + * address will collect trade surplus when applicable. Otherwise, trade + * surplus will not be collected. + * Note: Trade surplus is only sent to this address for sells. It is a no-op + * for buys. Read more about "Can I collect trade surplus?" here in the FAQs. + */ + feeRecipientTradeSurplus?: string + + /** + * A boolean field. If set to true, the 0x Swap API quote request should + * sell the entirety of the caller's takerToken balance. A sellAmount is + * still required, even if it is a best guess, because it is how a reasonable + * minimum received amount is determined after slippage. + * Note: This parameter is only required for special cases, such as when + * setting up a multi-step transaction or composable operation, where the + * entire balance is not known ahead of time. Read more about "Is there a + * way to sell assets via Swap API if the exact sellToken amount is not known + * before the transaction is executed?" here in the FAQs. + */ + shouldSellEntireBalance?: string +} + +/** + * The response object from the 0x API /quote endpoint. + */ +export type SwapQuoteResponse = Readonly<{ + /** + * If buyAmount was specified in the request, it provides the price of buyToken + * in sellToken and vice versa. This price does not include the slippage + * provided in the request above, and therefore represents the best possible + * price. + * + * If buyTokenPercentageFee and feeRecipient were set, the fee amount will be + * part of this returned price. + */ + 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 price which must be met or else the entire transaction will revert. This + * price is influenced by the slippagePercentage parameter. On-chain sources + * may encounter price movements from quote to settlement. + */ + guaranteedPrice: string + + /** + * When priceImpactProtectionPercentage is set, this value returns the estimated + * change in the price of the specified asset that would be caused by the + * executed swap due to price impact. + * + * Note: If we fail to estimate price change we will return null. + * + * Read more about price impact protection + * [here](https://0x.org/docs/0x-swap-api/advanced-topics/price-impact-protection). + */ + estimatedPriceImpact: string | null + + /** + * The address of the contract to send call data to. + */ + to: string + + /** + * The call data required to be sent to the to contract address. + */ + data: string + + /** + * The amount of ether (in wei) that should be sent with the transaction. + * (Assuming protocolFee is paid in ether). + */ + value: string + + /** + * The gas price (in wei) that should be used to send the transaction. The + * transaction needs to be sent with this gasPrice or lower for the transaction + * to be successful. + */ + gasPrice: string + + /** + * The estimated gas limit that should be used to send the transaction to + * guarantee settlement. While a computed estimate is returned in all + * responses, an accurate estimate will only be returned if a takerAddress is + * included in the request. + */ + gas: string + + /** + * The estimate for the amount of gas that will actually be used in the + * transaction. Always less than gas. + */ + estimatedGas: string + + /** + * The maximum amount of ether that will be paid towards the protocol fee (in + * wei), and what is used to compute the value field of the transaction. + * + * Note, as of [ZEIP-91](https://governance.0xprotocol.org/vote/zeip-91), + * protocol fees have been removed for all order types. + */ + protocolFee: string + + /** + * The minimum amount of ether that will be paid towards the protocol fee (in + * wei) during the transaction. + */ + minimumProtocolFee: string + + /** + * The amount of buyToken (in buyToken units) that would be bought in this swap. + * Certain on-chain sources do not allow specifying buyAmount, when using + * buyAmount these sources are excluded. + */ + buyAmount: string + + /** + * Similar to buyAmount but with fees removed. This is the buyAmount as if no + * fee is charged. + */ + grossBuyAmount: string + + /** + * The amount of sellToken (in sellToken units) that would be sold in this swap. + * Specifying sellAmount is the recommended way to interact with 0xAPI as it + * covers all on-chain sources. + */ + sellAmount: string + + /** + * Similar to sellAmount but with fees removed. This is the sellAmount as if no + * fee is charged. Note: Currently, this will be the same as sellAmount as fees + * can only be configured to occur on the buyToken. + */ + grossSellAmount: string + + /** + * The percentage distribution of buyAmount or sellAmount split between each + * liquidity source. Ex: [{ name: '0x', proportion: "0.8" }, { name: 'Kyber', + * proportion: "0.2"}, ...] + */ + sources: Array<{ name: string; proportion: string }> + + /** + * The ERC20 token address of the token you want to receive in quote. + */ + buyTokenAddress: string + + /** + * The ERC20 token address of the token you want to sell with quote. + */ + sellTokenAddress: string + + /** + * The target contract address for which the user needs to have an allowance in + * order to be able to complete the swap. Typically this is the [0x Exchange + * Proxy contract address](https://0x.org/docs/introduction/0x-cheat-sheet#exchange-proxy-addresses) + * for the specified chain. For swaps with "ETH" as `sellToken`, wrapping "ETH" + * to "WETH" or unwrapping "WETH" to "ETH" no allowance is needed, a null + * address of `0x0000000000000000000000000000000000000000` is then returned + * instead. + */ + allowanceTarget: string + + /** + * The details used to fill orders, used by market makers. If orders is not + * empty, there will be a type on each order. For wrap/unwrap, orders is empty. + * otherwise, should be populated. + */ + orders: unknown + + /** + * The rate between ETH and sellToken. + */ + sellTokenToEthRate: string + + /** + * The rate between ETH and buyToken. + */ + buyTokenToEthRate: string + + /** + * 0x Swap API fees that would be charged. 0x takes an on-chain fee on swaps + * involving a select few token pairs for the Free and Starter tiers. This fee + * is charged on-chain to the users of your app during the transaction. If you + * are on the Growth tier, we completely waive this fee for your customers. + * Read more about it on our [pricing page](https://0x.org/pricing). + * + * This objects contains the zeroExFee object. See details about this fee type + * below. + */ + fees: { + /** + * Related to fees param above. + * + * Fee that 0x charges: + * - feeType: volume which means 0x would charge a certain percentage of the + * trade. + * - feeToken: The ERC20 token address to charge fee. + * - feeAmount: The amount of feeToken to be charged as the 0x fee. + * - billingType: The method that 0x fee is transferred. It can currently + * only be on-chain which means the fee would be charged on-chain. + */ + zeroExFee: { + feeType: string + feeToken: string + feeAmount: string + billingType: string + } + } +}> + +export const asSwapQuoteResponse = asJSON( + asObject({ + price: asString, + grossPrice: asString, + guaranteedPrice: asString, + estimatedPriceImpact: asEither(asString, asNull), + to: asString, + data: asString, + value: asString, + gasPrice: asString, + gas: asString, + estimatedGas: asString, + protocolFee: asString, + minimumProtocolFee: asString, + buyAmount: asString, + grossBuyAmount: asString, + sellAmount: asString, + grossSellAmount: asString, + sources: asArray(asObject({ name: asString, proportion: asString })), + buyTokenAddress: asString, + sellTokenAddress: asString, + allowanceTarget: asString, + orders: asUnknown, + sellTokenToEthRate: asString, + buyTokenToEthRate: asString, + fees: asObject({ + zeroExFee: asObject({ + feeType: asString, + feeToken: asString, + feeAmount: asString, + billingType: asString + }) + }) + }) +) From 7440cb71e48fb2eb190b3a7577c1e6ab4d940344 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Wed, 26 Jun 2024 16:14:22 -0700 Subject: [PATCH 2/4] Implement 0xgasless approve method --- src/swap/defi/0x/0xGasless.ts | 107 +++++++++++++++++++++++- src/swap/defi/0x/ZeroXApi.ts | 73 ++++++++++++++++- src/swap/defi/0x/util.ts | 22 +++++ src/swap/defi/0x/zeroXApiTypes.ts | 131 ++++++++++++++++++++++++++---- 4 files changed, 313 insertions(+), 20 deletions(-) diff --git a/src/swap/defi/0x/0xGasless.ts b/src/swap/defi/0x/0xGasless.ts index 4e79f795..ec165b47 100644 --- a/src/swap/defi/0x/0xGasless.ts +++ b/src/swap/defi/0x/0xGasless.ts @@ -5,16 +5,23 @@ import { EdgeSwapApproveOptions, EdgeSwapInfo, EdgeSwapQuote, - EdgeSwapResult + EdgeSwapResult, + EdgeTransaction } from 'edge-core-js/types' +import { snooze } from '../../../util/utils' import { EXPIRATION_MS, NATIVE_TOKEN_ADDRESS } from './constants' import { asInitOptions } from './types' -import { getCurrencyCode, getTokenAddress } from './util' +import { getCurrencyCode, getTokenAddress, makeSignatureStruct } from './util' import { ZeroXApi } from './ZeroXApi' +import { + GaslessSwapStatusResponse, + GaslessSwapSubmitRequest +} from './zeroXApiTypes' const swapInfo: EdgeSwapInfo = { displayName: '0x Gasless Swap', + isDex: true, pluginId: '0xgasless', supportEmail: 'support@edge.app' } @@ -66,6 +73,7 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { swapRequest.fromWallet.currencyInfo.pluginId ) const apiSwapQuote = await api.gaslessSwapQuote(chainId, { + checkApproval: true, sellToken: fromTokenAddress ?? NATIVE_TOKEN_ADDRESS, buyToken: toTokenAddress ?? NATIVE_TOKEN_ADDRESS, takerAddress: fromWalletAddress, @@ -75,6 +83,17 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { if (!apiSwapQuote.liquidityAvailable) throw new Error('No liquidity available') + // The plugin only supports gasless swaps, so if approval is required + // it must be gasless. + if ( + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + apiSwapQuote.approval != null && + apiSwapQuote.approval.isRequired && + !apiSwapQuote.approval.isGaslessAvailable + ) { + throw new Error('Approval is required but gasless is not available') + } + const { gasFee, zeroExFee } = apiSwapQuote.fees if ( @@ -101,7 +120,89 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { approve: async ( opts?: EdgeSwapApproveOptions ): Promise => { - throw new Error('Approve not yet implemented') + let approvalData: GaslessSwapSubmitRequest['approval'] + if ( + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + apiSwapQuote.approval != null && + apiSwapQuote.approval.isRequired + ) { + // Assert that that approval is gasless, otherwise it would have + // been caught above, so this case should be unreachable. + if (!apiSwapQuote.approval.isGaslessAvailable) { + throw new Error('Unreachable non-gasless approval condition') + } + + const approvalTypeData = JSON.stringify( + apiSwapQuote.approval.eip712 + ) + const approvalSignatureHash = await swapRequest.fromWallet.signMessage( + approvalTypeData, + { otherParams: { typedData: true } } + ) + const approvalSignature = makeSignatureStruct(approvalSignatureHash) + approvalData = { + type: apiSwapQuote.approval.type, + eip712: apiSwapQuote.approval.eip712, + signature: approvalSignature + } + } + + const tradeTypeData = JSON.stringify(apiSwapQuote.trade.eip712) + const tradeSignatureHash = await swapRequest.fromWallet.signMessage( + tradeTypeData, + { otherParams: { typedData: true } } + ) + const tradeSignature = makeSignatureStruct(tradeSignatureHash) + const tradeData: GaslessSwapSubmitRequest['trade'] = { + type: apiSwapQuote.trade.type, + eip712: apiSwapQuote.trade.eip712, + signature: tradeSignature + } + + const apiSwapSubmition = await api.gaslessSwapSubmit(chainId, { + ...(approvalData !== undefined ? { approval: approvalData } : {}), + trade: tradeData + }) + + let apiSwapStatus: GaslessSwapStatusResponse + do { + // Wait before checking + await snooze(500) + apiSwapStatus = await api.gaslessSwapStatus( + chainId, + apiSwapSubmition.tradeHash + ) + } while (apiSwapStatus.status === 'pending') + + if (apiSwapStatus.status === 'failed') { + throw new Error(`Swap failed: ${apiSwapStatus.reason ?? 'unknown'}`) + } + + // Create the minimal transaction object for the swap. + // Some values may be updated later when the transaction is + // updated from queries to the network. + const transaction: EdgeTransaction = { + blockHeight: 0, + currencyCode: fromCurrencyCode, + date: Date.now(), + isSend: true, + memos: [], + nativeAmount: swapRequest.nativeAmount, + networkFee: networkFee.nativeAmount, + ourReceiveAddresses: [], + signedTx: '', + tokenId: swapRequest.fromTokenId, + txid: apiSwapStatus.transactions[0].hash, + walletId: swapRequest.fromWallet.id + } + + // Don't forget to save the transaction to the wallet: + await swapRequest.fromWallet.saveTx(transaction) + + return { + orderId: apiSwapSubmition.tradeHash, + transaction + } }, close: async () => {}, expirationDate: new Date(Date.now() + EXPIRATION_MS), diff --git a/src/swap/defi/0x/ZeroXApi.ts b/src/swap/defi/0x/ZeroXApi.ts index 8ae35d82..577b6e75 100644 --- a/src/swap/defi/0x/ZeroXApi.ts +++ b/src/swap/defi/0x/ZeroXApi.ts @@ -5,9 +5,14 @@ import { FetchResponse } from 'serverlet' import { asErrorResponse, asGaslessSwapQuoteResponse, + asGaslessSwapStatusResponse, + asGaslessSwapSubmitResponse, ChainId, GaslessSwapQuoteRequest, GaslessSwapQuoteResponse, + GaslessSwapStatusResponse, + GaslessSwapSubmitRequest, + GaslessSwapSubmitResponse, SwapQuoteRequest } from './zeroXApiTypes' @@ -100,7 +105,7 @@ export class ZeroXApi { chainId: ChainId, request: GaslessSwapQuoteRequest ): Promise { - // Gasless API is only available on Ethereum network + // Gasless API uses the Ethereum network const endpoint = this.getEndpointFromPluginId('ethereum') const queryParams = requestToParams(request) @@ -126,6 +131,72 @@ export class ZeroXApi { return responseData } + + /** + * Submits a gasless swap request to the 0x API. + * + * @param chainId - The chain ID of the network. + * @param request - The gasless swap submit request. + * @returns A promise that resolves to the gasless swap response. + */ + async gaslessSwapSubmit( + chainId: ChainId, + request: GaslessSwapSubmitRequest + ): Promise { + // Gasless API uses the Ethereum network + const endpoint = this.getEndpointFromPluginId('ethereum') + + const response = await this.io.fetch( + `${endpoint}/tx-relay/v1/swap/submit`, + { + method: 'POST', + body: JSON.stringify(request), + 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 = asGaslessSwapSubmitResponse(responseText) + + return responseData + } + + async gaslessSwapStatus( + chainId: ChainId, + tradeHash: string + ): Promise { + // Gasless API uses the Ethereum network + const endpoint = this.getEndpointFromPluginId('ethereum') + + const response = await this.io.fetch( + `${endpoint}/tx-relay/v1/swap/status/${tradeHash}`, + { + method: 'GET', + 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 = asGaslessSwapStatusResponse(responseText) + + return responseData + } } async function handledErrorResponse(response: FetchResponse): Promise { diff --git a/src/swap/defi/0x/util.ts b/src/swap/defi/0x/util.ts index 22b4cf8b..f7d79458 100644 --- a/src/swap/defi/0x/util.ts +++ b/src/swap/defi/0x/util.ts @@ -1,5 +1,9 @@ +import { secp256k1 } from '@noble/curves/secp256k1' import { EdgeCurrencyWallet, EdgeTokenId } from 'edge-core-js/types' +import { hexToDecimal } from '../../../util/utils' +import { SignatureStruct, SignatureType } from './zeroXApiTypes' + /** * Retrieves the currency code for a given token ID on a given currency wallet. * @@ -43,3 +47,21 @@ export const getTokenAddress = ( throw new Error('Missing contractAddress in EdgeToken networkLocation') return address } + +/** + * Creates a signature struct from a signature hash. This signature struct + * data type is used in the 0x Gasless Swap API when submitting the swap + * transaction over the API tx-relay. + * + * @param signatureHash The signature hash. + * @returns The signature struct. + */ +export function makeSignatureStruct(signatureHash: string): SignatureStruct { + const signature = secp256k1.Signature.fromCompact(signatureHash.slice(2, 130)) + return { + v: parseInt(hexToDecimal(`0x${signatureHash.slice(130)}`)), + r: `0x${signature.r.toString(16)}`, + s: `0x${signature.s.toString(16)}`, + signatureType: SignatureType.EIP712 + } +} diff --git a/src/swap/defi/0x/zeroXApiTypes.ts b/src/swap/defi/0x/zeroXApiTypes.ts index 77fd58c1..520dadf6 100644 --- a/src/swap/defi/0x/zeroXApiTypes.ts +++ b/src/swap/defi/0x/zeroXApiTypes.ts @@ -1,6 +1,5 @@ import { asArray, - asBoolean, asEither, asJSON, asNull, @@ -40,6 +39,10 @@ export const asErrorResponse = asJSON( // Gasless API // ----------------------------------------------------------------------------- +// +// Gasless Swap Quote +// + export interface GaslessSwapQuoteRequest { /** * The contract address of the token being bought. Use @@ -349,7 +352,7 @@ interface GaslessSwapQuoteResponseLiquidity { * `trade.eip712.message` as they will change based on the `type` field and * we would add more types in the future. */ - trade?: { + trade: { type: string hash: string eip712: any @@ -378,13 +381,16 @@ interface GaslessSwapQuoteResponseLiquidity { * 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 - } + approval?: + | { isRequired: false } + | { isRequired: true; isGaslessAvailable: false } + | { + isRequired: true + isGaslessAvailable: true + type: string + hash: string + eip712: any + } } export const asGaslessSwapQuoteResponse = asJSON( @@ -430,18 +436,111 @@ export const asGaslessSwapQuoteResponse = asJSON( }) }), - trade: asOptional( - asObject({ type: asString, hash: asString, eip712: asUnknown }) - ), + trade: asObject({ type: asString, hash: asString, eip712: asUnknown }), approval: asOptional( + asEither( + asObject({ + isRequired: asValue(false) + }), + asObject({ + isRequired: asValue(true), + isGaslessAvailable: asValue(false) + }), + asObject({ + isRequired: asValue(true), + isGaslessAvailable: asValue(true), + type: asString, + hash: asString, + eip712: asUnknown + }) + ) + ) + }) + ) +) + +// +// Gasless Swap Submit +// + +export enum SignatureType { + Illegal = 0, + Invalid = 1, + EIP712 = 2, + EthSign = 3 +} + +export interface SignatureStruct { + v: number + r: string + s: string + signatureType: SignatureType +} + +export interface GaslessSwapSubmitRequest { + approval?: { + /** This is `approval.`type from the `/quote` endpoint */ + type: string + /** This is `approval.eip712` from the `/quote` endpoint */ + eip712: any + signature: SignatureStruct + } + trade: { + /** This is `trade.`type from the `/quote` endpoint */ + type: string + /** This is `trade.eip712` from the `/quote` endpoint */ + eip712: any + signature: SignatureStruct + } +} + +export interface GaslessSwapSubmitResponse { + type: 'metatransaction_v2' | 'otc' + tradeHash: string +} + +export const asGaslessSwapSubmitResponse = asJSON( + asObject({ + type: asValue('metatransaction_v2', 'otc'), + tradeHash: asString + }) +) + +// +// Gasless Swap Status +// + +export type GaslessSwapStatusResponse = { + transactions: Array<{ hash: string; timestamp: number /* unix ms */ }> + // For pending, expect no transactions. + // For successful transactions (i.e. "succeeded"/"confirmed), expect just the mined transaction. + // For failed transactions, there may be 0 (failed before submission) to multiple transactions (transaction reverted). + // For submitted transactions, there may be multiple transactions, but only one will ultimately get mined +} & ( + | { status: 'pending' | 'submitted' | 'succeeded' | 'confirmed' } + | { status: 'failed'; reason: string } +) + +export const asGaslessSwapStatusResponse = asJSON( + asEither( + asObject({ + status: asValue('pending', 'submitted', 'succeeded', 'confirmed'), + transactions: asArray( asObject({ - isRequired: asBoolean, - isGaslessAvailable: asBoolean, - type: asString, hash: asString, - eip712: asUnknown + timestamp: asNumber }) ) + }), + asObject({ + status: asValue('failed'), + transactions: asArray( + asObject({ + hash: asString, + timestamp: asNumber + }) + ), + reason: asString }) ) ) From b4cc78980a57148e98971603abbc280dba066ace Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Thu, 20 Jun 2024 15:29:33 -0700 Subject: [PATCH 3/4] Implement 0xgasless swap metadata --- CHANGELOG.md | 2 ++ src/swap/defi/0x/0xGasless.ts | 51 ++++++++++++++++++++++++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a723565..f205a9fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- added: 0x Gasless Swap plugin + ## 2.6.0 (2024-06-24) - added: (Lifi) Add Solana diff --git a/src/swap/defi/0x/0xGasless.ts b/src/swap/defi/0x/0xGasless.ts index ec165b47..f49f1dac 100644 --- a/src/swap/defi/0x/0xGasless.ts +++ b/src/swap/defi/0x/0xGasless.ts @@ -1,12 +1,14 @@ import { add } from 'biggystring' import { + EdgeAssetAction, EdgeCorePluginFactory, EdgeNetworkFee, EdgeSwapApproveOptions, EdgeSwapInfo, EdgeSwapQuote, EdgeSwapResult, - EdgeTransaction + EdgeTransaction, + EdgeTxAction } from 'edge-core-js/types' import { snooze } from '../../../util/utils' @@ -35,13 +37,11 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { return { swapInfo, fetchSwapQuote: async (swapRequest): 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') + // The fromWallet and toWallet must be of the same because the swap + // service only supports swaps of the same network and for the same + // account/address. + if (swapRequest.toWallet.id !== swapRequest.fromWallet.id) { + throw new Error('Swap between different wallets is not supported') } const fromTokenAddress = getTokenAddress( @@ -178,10 +178,40 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { throw new Error(`Swap failed: ${apiSwapStatus.reason ?? 'unknown'}`) } + const assetAction: EdgeAssetAction = { + assetActionType: 'swap' + } + const orderId = apiSwapSubmition.tradeHash + + const savedAction: EdgeTxAction = { + actionType: 'swap', + canBePartial: false, + isEstimate: false, + fromAsset: { + pluginId: swapRequest.fromWallet.currencyInfo.pluginId, + tokenId: swapRequest.fromTokenId, + nativeAmount: swapRequest.nativeAmount + }, + orderId, + // The payout address is the same as the fromWalletAddress because + // the swap service only supports swaps of the same network and + // account/address. + payoutAddress: fromWalletAddress, + payoutWalletId: swapRequest.toWallet.id, + refundAddress: fromWalletAddress, + swapInfo, + toAsset: { + pluginId: swapRequest.toWallet.currencyInfo.pluginId, + tokenId: swapRequest.toTokenId, + nativeAmount: apiSwapQuote.buyAmount + } + } + // Create the minimal transaction object for the swap. // Some values may be updated later when the transaction is // updated from queries to the network. const transaction: EdgeTransaction = { + assetAction, blockHeight: 0, currencyCode: fromCurrencyCode, date: Date.now(), @@ -190,7 +220,8 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { nativeAmount: swapRequest.nativeAmount, networkFee: networkFee.nativeAmount, ourReceiveAddresses: [], - signedTx: '', + savedAction, + signedTx: '', // Signing is done by the tx-relay server tokenId: swapRequest.fromTokenId, txid: apiSwapStatus.transactions[0].hash, walletId: swapRequest.fromWallet.id @@ -200,7 +231,7 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { await swapRequest.fromWallet.saveTx(transaction) return { - orderId: apiSwapSubmition.tradeHash, + orderId, transaction } }, From e3f826d006ffeb628b668e9d2886211b79ab4aa5 Mon Sep 17 00:00:00 2001 From: Sam Holmes Date: Wed, 26 Jun 2024 16:16:31 -0700 Subject: [PATCH 4/4] Remove networkFee for a 0xgasless swap Fee is paid out of the fromToken amount, so in other words, the exchange rate is adjusted to account for the fee. This means the user will always expect to pay the `nativeAmount` with no fee, but this does impact the exchange rate used to calculate the buy-token (to-token) amount. --- src/swap/defi/0x/0xGasless.ts | 36 ++++++++++------------------------- 1 file changed, 10 insertions(+), 26 deletions(-) diff --git a/src/swap/defi/0x/0xGasless.ts b/src/swap/defi/0x/0xGasless.ts index f49f1dac..0aa8022a 100644 --- a/src/swap/defi/0x/0xGasless.ts +++ b/src/swap/defi/0x/0xGasless.ts @@ -1,8 +1,6 @@ -import { add } from 'biggystring' import { EdgeAssetAction, EdgeCorePluginFactory, - EdgeNetworkFee, EdgeSwapApproveOptions, EdgeSwapInfo, EdgeSwapQuote, @@ -94,28 +92,6 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { throw new Error('Approval is required but gasless is not 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 fromCurrencyCode = getCurrencyCode( - swapRequest.fromWallet, - swapRequest.fromTokenId - ) - const networkFee: EdgeNetworkFee = { - currencyCode: fromCurrencyCode, - nativeAmount: add(gasFee.feeAmount, zeroExFee.feeAmount) - } - return { approve: async ( opts?: EdgeSwapApproveOptions @@ -210,6 +186,10 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { // Create the minimal transaction object for the swap. // Some values may be updated later when the transaction is // updated from queries to the network. + const fromCurrencyCode = getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) const transaction: EdgeTransaction = { assetAction, blockHeight: 0, @@ -218,7 +198,8 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { isSend: true, memos: [], nativeAmount: swapRequest.nativeAmount, - networkFee: networkFee.nativeAmount, + // There is no fee for a gasless swap + networkFee: '0', ourReceiveAddresses: [], savedAction, signedTx: '', // Signing is done by the tx-relay server @@ -239,7 +220,10 @@ export const make0xGaslessPlugin: EdgeCorePluginFactory = opts => { expirationDate: new Date(Date.now() + EXPIRATION_MS), fromNativeAmount: apiSwapQuote.sellAmount, isEstimate: false, - networkFee, + networkFee: { + currencyCode: swapRequest.fromWallet.currencyInfo.currencyCode, + nativeAmount: '0' // There is no fee for a gasless swap + }, pluginId: swapInfo.pluginId, request: swapRequest, swapInfo: swapInfo,