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/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..0aa8022a --- /dev/null +++ b/src/swap/defi/0x/0xGasless.ts @@ -0,0 +1,234 @@ +import { + EdgeAssetAction, + EdgeCorePluginFactory, + EdgeSwapApproveOptions, + EdgeSwapInfo, + EdgeSwapQuote, + EdgeSwapResult, + EdgeTransaction, + EdgeTxAction +} 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, 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' +} + +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 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( + 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, { + checkApproval: true, + sellToken: fromTokenAddress ?? NATIVE_TOKEN_ADDRESS, + buyToken: toTokenAddress ?? NATIVE_TOKEN_ADDRESS, + takerAddress: fromWalletAddress, + [amountField]: swapRequest.nativeAmount + }) + + 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') + } + + return { + approve: async ( + opts?: EdgeSwapApproveOptions + ): Promise => { + 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'}`) + } + + 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 fromCurrencyCode = getCurrencyCode( + swapRequest.fromWallet, + swapRequest.fromTokenId + ) + const transaction: EdgeTransaction = { + assetAction, + blockHeight: 0, + currencyCode: fromCurrencyCode, + date: Date.now(), + isSend: true, + memos: [], + nativeAmount: swapRequest.nativeAmount, + // There is no fee for a gasless swap + networkFee: '0', + ourReceiveAddresses: [], + savedAction, + signedTx: '', // Signing is done by the tx-relay server + 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, + transaction + } + }, + close: async () => {}, + expirationDate: new Date(Date.now() + EXPIRATION_MS), + fromNativeAmount: apiSwapQuote.sellAmount, + isEstimate: false, + networkFee: { + currencyCode: swapRequest.fromWallet.currencyInfo.currencyCode, + nativeAmount: '0' // There is no fee for a gasless swap + }, + 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..577b6e75 --- /dev/null +++ b/src/swap/defi/0x/ZeroXApi.ts @@ -0,0 +1,240 @@ +import { asMaybe } from 'cleaners' +import { EdgeIo } from 'edge-core-js' +import { FetchResponse } from 'serverlet' + +import { + asErrorResponse, + asGaslessSwapQuoteResponse, + asGaslessSwapStatusResponse, + asGaslessSwapSubmitResponse, + ChainId, + GaslessSwapQuoteRequest, + GaslessSwapQuoteResponse, + GaslessSwapStatusResponse, + GaslessSwapSubmitRequest, + GaslessSwapSubmitResponse, + 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 uses the 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 + } + + /** + * 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 { + 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..f7d79458 --- /dev/null +++ b/src/swap/defi/0x/util.ts @@ -0,0 +1,67 @@ +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. + * + * @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 +} + +/** + * 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 new file mode 100644 index 00000000..520dadf6 --- /dev/null +++ b/src/swap/defi/0x/zeroXApiTypes.ts @@ -0,0 +1,939 @@ +import { + asArray, + 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 +// ----------------------------------------------------------------------------- + +// +// Gasless Swap Quote +// + +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: false } + | { isRequired: true; isGaslessAvailable: false } + | { + isRequired: true + isGaslessAvailable: true + 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: 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({ + hash: asString, + timestamp: asNumber + }) + ) + }), + asObject({ + status: asValue('failed'), + transactions: asArray( + asObject({ + hash: asString, + timestamp: asNumber + }) + ), + reason: asString + }) + ) +) + +// ----------------------------------------------------------------------------- +// 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 + }) + }) + }) +)