diff --git a/.env.dev b/.env.dev index 160e68be569..eaeaad6121c 100644 --- a/.env.dev +++ b/.env.dev @@ -5,7 +5,7 @@ REACT_APP_FEATURE_SWAPPER_SOLANA=true # Swapper feature flags REACT_APP_FEATURE_CHAINFLIP_SWAP=true -REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA=false +REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA=true REACT_APP_FEATURE_PUBLIC_TRADE_ROUTE=true REACT_APP_FEATURE_LIMIT_ORDERS=true diff --git a/.env.develop b/.env.develop index 45a2c7236f3..ed22c4da35e 100644 --- a/.env.develop +++ b/.env.develop @@ -7,7 +7,7 @@ REACT_APP_FEATURE_SWAPPER_SOLANA=true # Swapper feature flags REACT_APP_FEATURE_LIMIT_ORDERS=true REACT_APP_FEATURE_CHAINFLIP_SWAP=true -REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA=false +REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA=true # mixpanel REACT_APP_MIXPANEL_TOKEN=1c1369f6ea23a6404bac41b42817cc4b diff --git a/packages/swapper/src/swappers/ChainflipSwapper/constants.ts b/packages/swapper/src/swappers/ChainflipSwapper/constants.ts index b358a70823e..7959e6d64dc 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/constants.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/constants.ts @@ -17,8 +17,8 @@ import type { SupportedChainIds, SwapSource } from '../../types' import { SwapperName } from '../../types' import { ChainflipNetwork } from './types' -export const CHAINFLIP_REGULAR_QUOTE = 'regular' -export const CHAINFLIP_DCA_QUOTE = 'dca' +export const CHAINFLIP_REGULAR_QUOTE = 'regular' as const +export const CHAINFLIP_DCA_QUOTE = 'dca' as const export const CHAINFLIP_BAAS_COMMISSION = 5 export const ChainflipSupportedChainIds = [ diff --git a/packages/swapper/src/swappers/ChainflipSwapper/endpoints.ts b/packages/swapper/src/swappers/ChainflipSwapper/endpoints.ts index 8780b7a3f37..2b346edd15c 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/endpoints.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/endpoints.ts @@ -4,7 +4,6 @@ import { FeeDataKey } from '@shapeshiftoss/chain-adapters' import type { BTCSignTx, SolanaSignTx } from '@shapeshiftoss/hdwallet-core' import type { EvmChainId, KnownChainIds } from '@shapeshiftoss/types' import { TxStatus } from '@shapeshiftoss/unchained-client' -import type { AxiosError } from 'axios' import type { InterpolationOptions } from 'node-polyglot' import type { @@ -16,18 +15,12 @@ import type { UtxoFeeData, } from '../../types' import { isExecutableTradeQuote, isExecutableTradeStep, isToken } from '../../utils' -import { CHAINFLIP_BAAS_COMMISSION, CHAINFLIP_BOOST_SWAP_SOURCE } from './constants' -import type { ChainflipBaasSwapDepositAddress } from './models/ChainflipBaasSwapDepositAddress' +import type { ChainflipBaasSwapDepositAddress } from './models' import { getTradeQuote } from './swapperApi/getTradeQuote' import { getTradeRate } from './swapperApi/getTradeRate' import type { ChainFlipStatus } from './types' import { chainflipService } from './utils/chainflipService' import { getLatestChainflipStatusMessage } from './utils/getLatestChainflipStatusMessage' -import { - calculateChainflipMinPrice, - getChainFlipIdFromAssetId, - getChainFlipSwap, -} from './utils/helpers' // Persists the ID so we can look it up later when checking the status const tradeQuoteMetadata: Map = new Map() @@ -40,70 +33,25 @@ export const chainflipApi: SwapperApi = { from, tradeQuote, assertGetEvmChainAdapter, - config, supportsEIP1559, }: GetUnsignedEvmTransactionArgs): Promise => { if (!isExecutableTradeQuote(tradeQuote)) throw Error('Unable to execute trade') - const brokerUrl = config.REACT_APP_CHAINFLIP_API_URL - const apiKey = config.REACT_APP_CHAINFLIP_API_KEY - const step = tradeQuote.steps[0] - const isTokenSend = isToken(step.sellAsset.assetId) - const sourceAsset = await getChainFlipIdFromAssetId({ - assetId: step.sellAsset.assetId, - brokerUrl, - }) - const destinationAsset = await getChainFlipIdFromAssetId({ - assetId: step.buyAsset.assetId, - brokerUrl, - }) - - const minimumPrice = calculateChainflipMinPrice({ - slippageTolerancePercentageDecimal: tradeQuote.slippageTolerancePercentageDecimal, - sellAsset: step.sellAsset, - buyAsset: step.buyAsset, - buyAmountAfterFeesCryptoBaseUnit: step.buyAmountAfterFeesCryptoBaseUnit, - sellAmountIncludingProtocolFeesCryptoBaseUnit: - step.sellAmountIncludingProtocolFeesCryptoBaseUnit, - }) - - // Subtract the BaaS fee to end up at the final displayed commissionBps - let serviceCommission = parseInt(tradeQuote.affiliateBps) - CHAINFLIP_BAAS_COMMISSION - if (serviceCommission < 0) serviceCommission = 0 + if (!isExecutableTradeStep(step)) throw Error('Unable to execute step') + if (!step.chainflipSpecific?.chainflipDepositAddress) throw Error('Missing deposit address') - const maybeSwapResponse = await getChainFlipSwap({ - brokerUrl, - apiKey, - sourceAsset, - destinationAsset, - destinationAddress: tradeQuote.receiveAddress, - minimumPrice, - refundAddress: from, - commissionBps: serviceCommission, - boostFee: 0, + tradeQuoteMetadata.set(tradeQuote.id, { + id: step.chainflipSpecific?.chainflipSwapId, + address: step.chainflipSpecific?.chainflipDepositAddress, }) - if (maybeSwapResponse.isErr()) { - const error = maybeSwapResponse.unwrapErr() - const cause = error.cause as AxiosError - throw Error(cause.response!.data.detail) - } - - const { data: swapResponse } = maybeSwapResponse.unwrap() - - if (!swapResponse.id) throw Error('missing swap ID') - - tradeQuoteMetadata.set(tradeQuote.id, swapResponse) - - const depositAddress = swapResponse.address! const { assetReference } = fromAssetId(step.sellAsset.assetId) - const adapter = assertGetEvmChainAdapter(step.sellAsset.chainId) - + const isTokenSend = isToken(step.sellAsset.assetId) const getFeeDataInput: GetFeeDataInput = { - to: depositAddress, + to: step.chainflipSpecific?.chainflipDepositAddress, value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, chainSpecific: { from, @@ -115,10 +63,8 @@ export const chainflipApi: SwapperApi = { const feeData = await adapter.getFeeData(getFeeDataInput) const fees = feeData[FeeDataKey.Average] - if (!isExecutableTradeStep(step)) throw Error('Unable to execute trade step') - const unsignedTxInput = await adapter.buildSendApiTransaction({ - to: depositAddress, + to: step.chainflipSpecific?.chainflipDepositAddress, from, value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, accountNumber: step.accountNumber, @@ -148,79 +94,36 @@ export const chainflipApi: SwapperApi = { gasPrice: unsignedTxInput.gasPrice, } }, - getUnsignedUtxoTransaction: async ({ + getUnsignedUtxoTransaction: ({ tradeQuote, senderAddress, xpub, accountType, assertGetUtxoChainAdapter, - config, }: GetUnsignedUtxoTransactionArgs): Promise => { if (!isExecutableTradeQuote(tradeQuote)) throw Error('Unable to execute trade') - const brokerUrl = config.REACT_APP_CHAINFLIP_API_URL - const apiKey = config.REACT_APP_CHAINFLIP_API_KEY - const step = tradeQuote.steps[0] if (!isExecutableTradeStep(step)) throw Error('Unable to execute step') + if (!step.chainflipSpecific?.chainflipDepositAddress) throw Error('Missing deposit address') - const sourceAsset = await getChainFlipIdFromAssetId({ - assetId: step.sellAsset.assetId, - brokerUrl, - }) - const destinationAsset = await getChainFlipIdFromAssetId({ - assetId: step.buyAsset.assetId, - brokerUrl, - }) - - // Subtract the BaaS fee to end up at the final displayed commissionBps - let serviceCommission = parseInt(tradeQuote.affiliateBps) - CHAINFLIP_BAAS_COMMISSION - if (serviceCommission < 0) serviceCommission = 0 - - const minimumPrice = calculateChainflipMinPrice({ - slippageTolerancePercentageDecimal: tradeQuote.slippageTolerancePercentageDecimal, - sellAsset: step.sellAsset, - buyAsset: step.buyAsset, - buyAmountAfterFeesCryptoBaseUnit: step.buyAmountAfterFeesCryptoBaseUnit, - sellAmountIncludingProtocolFeesCryptoBaseUnit: - step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + tradeQuoteMetadata.set(tradeQuote.id, { + id: step.chainflipSpecific?.chainflipSwapId, + address: step.chainflipSpecific?.chainflipDepositAddress, }) const adapter = assertGetUtxoChainAdapter(step.sellAsset.chainId) - const maybeSwapResponse = await getChainFlipSwap({ - brokerUrl, - apiKey, - sourceAsset, - destinationAsset, - destinationAddress: tradeQuote.receiveAddress, - minimumPrice, - refundAddress: senderAddress, - commissionBps: serviceCommission, - boostFee: step.source === CHAINFLIP_BOOST_SWAP_SOURCE ? 10 : 0, - }) - - if (maybeSwapResponse.isErr()) { - const error = maybeSwapResponse.unwrapErr() - const cause = error.cause as AxiosError - throw Error(cause.response!.data.detail) - } - - const { data: swapResponse } = maybeSwapResponse.unwrap() - - tradeQuoteMetadata.set(tradeQuote.id, swapResponse) - - const depositAddress = swapResponse.address! - return adapter.buildSendApiTransaction({ value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, xpub: xpub!, - to: depositAddress, + to: step.chainflipSpecific?.chainflipDepositAddress, accountNumber: step.accountNumber, skipToAddressValidation: true, chainSpecific: { accountType, + from: senderAddress, satoshiPerByte: (step.feeData.chainSpecific as UtxoFeeData).satsPerByte, }, }) @@ -229,61 +132,19 @@ export const chainflipApi: SwapperApi = { tradeQuote, from, assertGetSolanaChainAdapter, - config, }: GetUnsignedSolanaTransactionArgs): Promise => { if (!isExecutableTradeQuote(tradeQuote)) throw Error('Unable to execute trade') - const brokerUrl = config.REACT_APP_CHAINFLIP_API_URL - const apiKey = config.REACT_APP_CHAINFLIP_API_KEY - const step = tradeQuote.steps[0] if (!isExecutableTradeStep(step)) throw Error('Unable to execute step') + if (!step.chainflipSpecific?.chainflipDepositAddress) throw Error('Missing deposit address') - const sourceAsset = await getChainFlipIdFromAssetId({ - assetId: step.sellAsset.assetId, - brokerUrl, - }) - const destinationAsset = await getChainFlipIdFromAssetId({ - assetId: step.buyAsset.assetId, - brokerUrl, - }) - - // Subtract the BaaS fee to end up at the final displayed commissionBps - let serviceCommission = parseInt(tradeQuote.affiliateBps) - CHAINFLIP_BAAS_COMMISSION - if (serviceCommission < 0) serviceCommission = 0 - - const minimumPrice = calculateChainflipMinPrice({ - slippageTolerancePercentageDecimal: tradeQuote.slippageTolerancePercentageDecimal, - sellAsset: step.sellAsset, - buyAsset: step.buyAsset, - buyAmountAfterFeesCryptoBaseUnit: step.buyAmountAfterFeesCryptoBaseUnit, - sellAmountIncludingProtocolFeesCryptoBaseUnit: - step.sellAmountIncludingProtocolFeesCryptoBaseUnit, - }) - - const maybeSwapResponse = await getChainFlipSwap({ - brokerUrl, - apiKey, - sourceAsset, - destinationAsset, - destinationAddress: tradeQuote.receiveAddress, - minimumPrice, - refundAddress: from, - commissionBps: serviceCommission, - boostFee: 0, + tradeQuoteMetadata.set(tradeQuote.id, { + id: step.chainflipSpecific?.chainflipSwapId, + address: step.chainflipSpecific?.chainflipDepositAddress, }) - if (maybeSwapResponse.isErr()) { - const error = maybeSwapResponse.unwrapErr() - const cause = error.cause as AxiosError - throw Error(cause.response!.data.detail) - } - - const { data: swapResponse } = maybeSwapResponse.unwrap() - - tradeQuoteMetadata.set(tradeQuote.id, swapResponse) - const adapter = assertGetSolanaChainAdapter(step.sellAsset.chainId) const contractAddress = @@ -291,10 +152,8 @@ export const chainflipApi: SwapperApi = { ? undefined : fromAssetId(step.sellAsset.assetId).assetReference - const depositAddress = swapResponse.address! - const getFeeDataInput: GetFeeDataInput = { - to: depositAddress, + to: step.chainflipSpecific?.chainflipDepositAddress, value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, chainSpecific: { from, @@ -304,7 +163,7 @@ export const chainflipApi: SwapperApi = { const { fast } = await adapter.getFeeData(getFeeDataInput) const buildSendTxInput: BuildSendApiTxInput = { - to: depositAddress, + to: step.chainflipSpecific?.chainflipDepositAddress, from, value: step.sellAmountIncludingProtocolFeesCryptoBaseUnit, accountNumber: step.accountNumber, @@ -327,7 +186,7 @@ export const chainflipApi: SwapperApi = { message: string | [string, InterpolationOptions] | undefined }> => { const swap = tradeQuoteMetadata.get(quoteId) - if (!swap) throw Error(`missing trade quote metadata for quoteId ${quoteId}`) + if (!swap) throw Error(`Missing trade quote metadata for quoteId ${quoteId}`) // Note, the swapId isn't the quoteId - we set the swapId at pre-execution time, when getting the receive addy and instantiating a flip swap const swapId = swap.id diff --git a/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasQuoteBoostQuote.ts b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasQuoteBoostQuote.ts new file mode 100644 index 00000000000..1d82d9d85ca --- /dev/null +++ b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasQuoteBoostQuote.ts @@ -0,0 +1,201 @@ +// @ts-nocheck +/* tslint:disable */ +/* eslint-disable */ +/** + * Chainflip Broker as a Service + * Run your own Chainflip Broker without any hassle. + * + * The version of the OpenAPI document: v1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ChainflipBaasQuoteEstimatedDurationsSeconds } from './ChainflipBaasQuoteEstimatedDurationsSeconds'; +import { + ChainflipBaasQuoteEstimatedDurationsSecondsFromJSON, + ChainflipBaasQuoteEstimatedDurationsSecondsFromJSONTyped, + ChainflipBaasQuoteEstimatedDurationsSecondsToJSON, +} from './ChainflipBaasQuoteEstimatedDurationsSeconds'; +import type { ChainflipBaasQuotePoolInfo } from './ChainflipBaasQuotePoolInfo'; +import { + ChainflipBaasQuotePoolInfoFromJSON, + ChainflipBaasQuotePoolInfoFromJSONTyped, + ChainflipBaasQuotePoolInfoToJSON, +} from './ChainflipBaasQuotePoolInfo'; +import type { ChainflipBaasQuoteQuoteFee } from './ChainflipBaasQuoteQuoteFee'; +import { + ChainflipBaasQuoteQuoteFeeFromJSON, + ChainflipBaasQuoteQuoteFeeFromJSONTyped, + ChainflipBaasQuoteQuoteFeeToJSON, +} from './ChainflipBaasQuoteQuoteFee'; + +/** + * An optional quote to have the swap boosted. + * @export + * @interface ChainflipBaasQuoteBoostQuote + */ +export interface ChainflipBaasQuoteBoostQuote { + /** + * The asset to send. + * @type {string} + * @memberof ChainflipBaasQuoteBoostQuote + */ + ingressAsset?: string; + /** + * The amount to send. + * @type {number} + * @memberof ChainflipBaasQuoteBoostQuote + */ + ingressAmount?: number; + /** + * The amount to send in native units. + * @type {string} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly ingressAmountNative?: string; + /** + * The asset used as intermediary. + * @type {string} + * @memberof ChainflipBaasQuoteBoostQuote + */ + intermediateAsset?: string | null; + /** + * The amount used as intermediary. + * @type {number} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly intermediateAmount?: number | null; + /** + * The amount used as intermediary in native units. + * @type {string} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly intermediateAmountNative?: string | null; + /** + * The asset to receive. + * @type {string} + * @memberof ChainflipBaasQuoteBoostQuote + */ + egressAsset?: string; + /** + * The amount to receive. + * @type {number} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly egressAmount?: number; + /** + * The amount to receive in native units. + * @type {string} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly egressAmountNative?: string; + /** + * The fee structure, this includes all fees. + * @type {Array} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly includedFees?: Array; + /** + * A warning in case liquidity is low and there is a risk of high slippage. + * @type {boolean} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly lowLiquidityWarning?: boolean; + /** + * Liquidity pools involved in the swap, as well as estimated liquidity provider fees. + * @type {Array} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly poolInfo?: Array; + /** + * The estimated time the swap will take. + * @type {number} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly estimatedDurationSeconds?: number; + /** + * + * @type {ChainflipBaasQuoteEstimatedDurationsSeconds} + * @memberof ChainflipBaasQuoteBoostQuote + */ + estimatedDurationsSeconds?: ChainflipBaasQuoteEstimatedDurationsSeconds; + /** + * The estimated fee (in bps) that the user has to pay (from the deposit amount) to get this swap boosted. + * @type {number} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly estimatedBoostFeeBps?: number; + /** + * The number of "sub-swaps" to perform for a DCA swap. + * @type {number} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly chunkIntervalBlocks?: number | null; + /** + * The delay between the "sub-swaps" of a DCA swap in number of blocks. + * @type {number} + * @memberof ChainflipBaasQuoteBoostQuote + */ + readonly numberOfChunks?: number | null; +} + +/** + * Check if a given object implements the ChainflipBaasQuoteBoostQuote interface. + */ +export function instanceOfChainflipBaasQuoteBoostQuote(value: object): boolean { + let isInstance = true; + + return isInstance; +} + +export function ChainflipBaasQuoteBoostQuoteFromJSON(json: any): ChainflipBaasQuoteBoostQuote { + return ChainflipBaasQuoteBoostQuoteFromJSONTyped(json, false); +} + +export function ChainflipBaasQuoteBoostQuoteFromJSONTyped(json: any, ignoreDiscriminator: boolean): ChainflipBaasQuoteBoostQuote { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'ingressAsset': !exists(json, 'ingressAsset') ? undefined : json['ingressAsset'], + 'ingressAmount': !exists(json, 'ingressAmount') ? undefined : json['ingressAmount'], + 'ingressAmountNative': !exists(json, 'ingressAmountNative') ? undefined : json['ingressAmountNative'], + 'intermediateAsset': !exists(json, 'intermediateAsset') ? undefined : json['intermediateAsset'], + 'intermediateAmount': !exists(json, 'intermediateAmount') ? undefined : json['intermediateAmount'], + 'intermediateAmountNative': !exists(json, 'intermediateAmountNative') ? undefined : json['intermediateAmountNative'], + 'egressAsset': !exists(json, 'egressAsset') ? undefined : json['egressAsset'], + 'egressAmount': !exists(json, 'egressAmount') ? undefined : json['egressAmount'], + 'egressAmountNative': !exists(json, 'egressAmountNative') ? undefined : json['egressAmountNative'], + 'includedFees': !exists(json, 'includedFees') ? undefined : ((json['includedFees'] as Array).map(ChainflipBaasQuoteQuoteFeeFromJSON)), + 'lowLiquidityWarning': !exists(json, 'lowLiquidityWarning') ? undefined : json['lowLiquidityWarning'], + 'poolInfo': !exists(json, 'poolInfo') ? undefined : ((json['poolInfo'] as Array).map(ChainflipBaasQuotePoolInfoFromJSON)), + 'estimatedDurationSeconds': !exists(json, 'estimatedDurationSeconds') ? undefined : json['estimatedDurationSeconds'], + 'estimatedDurationsSeconds': !exists(json, 'estimatedDurationsSeconds') ? undefined : ChainflipBaasQuoteEstimatedDurationsSecondsFromJSON(json['estimatedDurationsSeconds']), + 'estimatedBoostFeeBps': !exists(json, 'estimatedBoostFeeBps') ? undefined : json['estimatedBoostFeeBps'], + 'chunkIntervalBlocks': !exists(json, 'chunkIntervalBlocks') ? undefined : json['chunkIntervalBlocks'], + 'numberOfChunks': !exists(json, 'numberOfChunks') ? undefined : json['numberOfChunks'], + }; +} + +export function ChainflipBaasQuoteBoostQuoteToJSON(value?: ChainflipBaasQuoteBoostQuote | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'ingressAsset': value.ingressAsset, + 'ingressAmount': value.ingressAmount, + 'intermediateAsset': value.intermediateAsset, + 'egressAsset': value.egressAsset, + 'estimatedDurationsSeconds': ChainflipBaasQuoteEstimatedDurationsSecondsToJSON(value.estimatedDurationsSeconds), + }; +} + diff --git a/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusChunkInfo.ts b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusChunkInfo.ts new file mode 100644 index 00000000000..32814c5a17f --- /dev/null +++ b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusChunkInfo.ts @@ -0,0 +1,149 @@ +// @ts-nocheck +/* tslint:disable */ +/* eslint-disable */ +/** + * Chainflip Broker as a Service + * Run your own Chainflip Broker without any hassle. + * + * The version of the OpenAPI document: v1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * + * @export + * @interface ChainflipBaasStatusChunkInfo + */ +export interface ChainflipBaasStatusChunkInfo { + /** + * + * @type {string} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly inputAmountNative?: string; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly inputAmount?: number; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly intermediateAmountNative?: string | null; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly intermediateAmount?: number | null; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly outputAmountNative?: string | null; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly outputAmount?: number | null; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly scheduledAt?: number; + /** + * + * @type {Date} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly scheduledAtDate?: Date; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly scheduledBlockIndex?: string; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly executedAt?: number | null; + /** + * + * @type {Date} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly executedAtDate?: Date | null; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly executedBlockIndex?: string | null; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusChunkInfo + */ + readonly retryCount?: number; +} + +/** + * Check if a given object implements the ChainflipBaasStatusChunkInfo interface. + */ +export function instanceOfChainflipBaasStatusChunkInfo(value: object): boolean { + let isInstance = true; + + return isInstance; +} + +export function ChainflipBaasStatusChunkInfoFromJSON(json: any): ChainflipBaasStatusChunkInfo { + return ChainflipBaasStatusChunkInfoFromJSONTyped(json, false); +} + +export function ChainflipBaasStatusChunkInfoFromJSONTyped(json: any, ignoreDiscriminator: boolean): ChainflipBaasStatusChunkInfo { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'inputAmountNative': !exists(json, 'inputAmountNative') ? undefined : json['inputAmountNative'], + 'inputAmount': !exists(json, 'inputAmount') ? undefined : json['inputAmount'], + 'intermediateAmountNative': !exists(json, 'intermediateAmountNative') ? undefined : json['intermediateAmountNative'], + 'intermediateAmount': !exists(json, 'intermediateAmount') ? undefined : json['intermediateAmount'], + 'outputAmountNative': !exists(json, 'outputAmountNative') ? undefined : json['outputAmountNative'], + 'outputAmount': !exists(json, 'outputAmount') ? undefined : json['outputAmount'], + 'scheduledAt': !exists(json, 'scheduledAt') ? undefined : json['scheduledAt'], + 'scheduledAtDate': !exists(json, 'scheduledAtDate') ? undefined : (new Date(json['scheduledAtDate'])), + 'scheduledBlockIndex': !exists(json, 'scheduledBlockIndex') ? undefined : json['scheduledBlockIndex'], + 'executedAt': !exists(json, 'executedAt') ? undefined : json['executedAt'], + 'executedAtDate': !exists(json, 'executedAtDate') ? undefined : (json['executedAtDate'] === null ? null : new Date(json['executedAtDate'])), + 'executedBlockIndex': !exists(json, 'executedBlockIndex') ? undefined : json['executedBlockIndex'], + 'retryCount': !exists(json, 'retryCount') ? undefined : json['retryCount'], + }; +} + +export function ChainflipBaasStatusChunkInfoToJSON(value?: ChainflipBaasStatusChunkInfo | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + }; +} + diff --git a/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusDca.ts b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusDca.ts new file mode 100644 index 00000000000..0f261b0950c --- /dev/null +++ b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusDca.ts @@ -0,0 +1,95 @@ +// @ts-nocheck +/* tslint:disable */ +/* eslint-disable */ +/** + * Chainflip Broker as a Service + * Run your own Chainflip Broker without any hassle. + * + * The version of the OpenAPI document: v1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ChainflipBaasStatusChunkInfo } from './ChainflipBaasStatusChunkInfo'; +import { + ChainflipBaasStatusChunkInfoFromJSON, + ChainflipBaasStatusChunkInfoFromJSONTyped, + ChainflipBaasStatusChunkInfoToJSON, +} from './ChainflipBaasStatusChunkInfo'; + +/** + * + * @export + * @interface ChainflipBaasStatusDca + */ +export interface ChainflipBaasStatusDca { + /** + * + * @type {ChainflipBaasStatusChunkInfo} + * @memberof ChainflipBaasStatusDca + */ + lastExecutedChunk?: ChainflipBaasStatusChunkInfo; + /** + * + * @type {ChainflipBaasStatusChunkInfo} + * @memberof ChainflipBaasStatusDca + */ + currentChunk?: ChainflipBaasStatusChunkInfo; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusDca + */ + readonly executedChunks?: number; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusDca + */ + readonly remainingChunks?: number; +} + +/** + * Check if a given object implements the ChainflipBaasStatusDca interface. + */ +export function instanceOfChainflipBaasStatusDca(value: object): boolean { + let isInstance = true; + + return isInstance; +} + +export function ChainflipBaasStatusDcaFromJSON(json: any): ChainflipBaasStatusDca { + return ChainflipBaasStatusDcaFromJSONTyped(json, false); +} + +export function ChainflipBaasStatusDcaFromJSONTyped(json: any, ignoreDiscriminator: boolean): ChainflipBaasStatusDca { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'lastExecutedChunk': !exists(json, 'lastExecutedChunk') ? undefined : ChainflipBaasStatusChunkInfoFromJSON(json['lastExecutedChunk']), + 'currentChunk': !exists(json, 'currentChunk') ? undefined : ChainflipBaasStatusChunkInfoFromJSON(json['currentChunk']), + 'executedChunks': !exists(json, 'executedChunks') ? undefined : json['executedChunks'], + 'remainingChunks': !exists(json, 'remainingChunks') ? undefined : json['remainingChunks'], + }; +} + +export function ChainflipBaasStatusDcaToJSON(value?: ChainflipBaasStatusDca | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'lastExecutedChunk': ChainflipBaasStatusChunkInfoToJSON(value.lastExecutedChunk), + 'currentChunk': ChainflipBaasStatusChunkInfoToJSON(value.currentChunk), + }; +} + diff --git a/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusSwap.ts b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusSwap.ts new file mode 100644 index 00000000000..45482daaaf7 --- /dev/null +++ b/packages/swapper/src/swappers/ChainflipSwapper/models/ChainflipBaasStatusSwap.ts @@ -0,0 +1,167 @@ +// @ts-nocheck +/* tslint:disable */ +/* eslint-disable */ +/** + * Chainflip Broker as a Service + * Run your own Chainflip Broker without any hassle. + * + * The version of the OpenAPI document: v1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { ChainflipBaasStatusChunkInfo } from './ChainflipBaasStatusChunkInfo'; +import { + ChainflipBaasStatusChunkInfoFromJSON, + ChainflipBaasStatusChunkInfoFromJSONTyped, + ChainflipBaasStatusChunkInfoToJSON, +} from './ChainflipBaasStatusChunkInfo'; +import type { ChainflipBaasStatusDca } from './ChainflipBaasStatusDca'; +import { + ChainflipBaasStatusDcaFromJSON, + ChainflipBaasStatusDcaFromJSONTyped, + ChainflipBaasStatusDcaToJSON, +} from './ChainflipBaasStatusDca'; + +/** + * + * @export + * @interface ChainflipBaasStatusSwap + */ +export interface ChainflipBaasStatusSwap { + /** + * + * @type {string} + * @memberof ChainflipBaasStatusSwap + */ + originalInputAmountNative?: string; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusSwap + */ + originalInputAmount?: number; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusSwap + */ + remainingInputAmountNative?: string; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusSwap + */ + remainingInputAmount?: number; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusSwap + */ + swappedInputAmountNative?: string; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusSwap + */ + swappedInputAmount?: number; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusSwap + */ + swappedIntermediateAmountNative?: string; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusSwap + */ + swappedIntermediateAmount?: number; + /** + * + * @type {string} + * @memberof ChainflipBaasStatusSwap + */ + swappedOutputAmountNative?: string; + /** + * + * @type {number} + * @memberof ChainflipBaasStatusSwap + */ + swappedOutputAmount?: number; + /** + * + * @type {ChainflipBaasStatusChunkInfo} + * @memberof ChainflipBaasStatusSwap + */ + regular?: ChainflipBaasStatusChunkInfo; + /** + * + * @type {ChainflipBaasStatusDca} + * @memberof ChainflipBaasStatusSwap + */ + dca?: ChainflipBaasStatusDca; +} + +/** + * Check if a given object implements the ChainflipBaasStatusSwap interface. + */ +export function instanceOfChainflipBaasStatusSwap(value: object): boolean { + let isInstance = true; + + return isInstance; +} + +export function ChainflipBaasStatusSwapFromJSON(json: any): ChainflipBaasStatusSwap { + return ChainflipBaasStatusSwapFromJSONTyped(json, false); +} + +export function ChainflipBaasStatusSwapFromJSONTyped(json: any, ignoreDiscriminator: boolean): ChainflipBaasStatusSwap { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'originalInputAmountNative': !exists(json, 'originalInputAmountNative') ? undefined : json['originalInputAmountNative'], + 'originalInputAmount': !exists(json, 'originalInputAmount') ? undefined : json['originalInputAmount'], + 'remainingInputAmountNative': !exists(json, 'remainingInputAmountNative') ? undefined : json['remainingInputAmountNative'], + 'remainingInputAmount': !exists(json, 'remainingInputAmount') ? undefined : json['remainingInputAmount'], + 'swappedInputAmountNative': !exists(json, 'swappedInputAmountNative') ? undefined : json['swappedInputAmountNative'], + 'swappedInputAmount': !exists(json, 'swappedInputAmount') ? undefined : json['swappedInputAmount'], + 'swappedIntermediateAmountNative': !exists(json, 'swappedIntermediateAmountNative') ? undefined : json['swappedIntermediateAmountNative'], + 'swappedIntermediateAmount': !exists(json, 'swappedIntermediateAmount') ? undefined : json['swappedIntermediateAmount'], + 'swappedOutputAmountNative': !exists(json, 'swappedOutputAmountNative') ? undefined : json['swappedOutputAmountNative'], + 'swappedOutputAmount': !exists(json, 'swappedOutputAmount') ? undefined : json['swappedOutputAmount'], + 'regular': !exists(json, 'regular') ? undefined : ChainflipBaasStatusChunkInfoFromJSON(json['regular']), + 'dca': !exists(json, 'dca') ? undefined : ChainflipBaasStatusDcaFromJSON(json['dca']), + }; +} + +export function ChainflipBaasStatusSwapToJSON(value?: ChainflipBaasStatusSwap | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'originalInputAmountNative': value.originalInputAmountNative, + 'originalInputAmount': value.originalInputAmount, + 'remainingInputAmountNative': value.remainingInputAmountNative, + 'remainingInputAmount': value.remainingInputAmount, + 'swappedInputAmountNative': value.swappedInputAmountNative, + 'swappedInputAmount': value.swappedInputAmount, + 'swappedIntermediateAmountNative': value.swappedIntermediateAmountNative, + 'swappedIntermediateAmount': value.swappedIntermediateAmount, + 'swappedOutputAmountNative': value.swappedOutputAmountNative, + 'swappedOutputAmount': value.swappedOutputAmount, + 'regular': ChainflipBaasStatusChunkInfoToJSON(value.regular), + 'dca': ChainflipBaasStatusDcaToJSON(value.dca), + }; +} + diff --git a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts index 7e9bf462fa8..c6b872fa248 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts @@ -1,363 +1,165 @@ -import type { AssetId } from '@shapeshiftoss/caip' import { CHAIN_NAMESPACE, fromAssetId, solAssetId } from '@shapeshiftoss/caip' import type { GetFeeDataInput } from '@shapeshiftoss/chain-adapters' import type { KnownChainIds } from '@shapeshiftoss/types' -import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' import type { AxiosError } from 'axios' -import { v4 as uuid } from 'uuid' -import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constants' import type { CommonTradeQuoteInput, - GetEvmTradeQuoteInput, - GetUtxoTradeQuoteInput, - ProtocolFee, - SwapErrorRight, + QuoteFeeData, SwapperDeps, - TradeQuote, + TradeQuoteResult, } from '../../../types' -import { SwapperName, TradeQuoteError } from '../../../types' -import { getInputOutputRate, makeSwapErrorRight } from '../../../utils' +import { TradeQuoteError } from '../../../types' +import { makeSwapErrorRight } from '../../../utils' +import { CHAINFLIP_BAAS_COMMISSION } from '../constants' +import { getRateOrQuote } from '../utils/getRateOrQuote' import { - CHAINFLIP_BAAS_COMMISSION, - CHAINFLIP_BOOST_SWAP_SOURCE, - CHAINFLIP_DCA_BOOST_SWAP_SOURCE, - CHAINFLIP_DCA_QUOTE, - CHAINFLIP_DCA_SWAP_SOURCE, - CHAINFLIP_REGULAR_QUOTE, - CHAINFLIP_SWAP_SOURCE, - usdcAsset, -} from '../constants' -import type { ChainflipBaasQuoteQuote, ChainflipBaasQuoteQuoteFee } from '../models' -import { chainflipService } from '../utils/chainflipService' -import { getEvmTxFees } from '../utils/getEvmTxFees' -import { getUtxoTxFees } from '../utils/getUtxoTxFees' -import { getChainFlipIdFromAssetId, isSupportedAssetId, isSupportedChainId } from '../utils/helpers' + calculateChainflipMinPrice, + getChainFlipIdFromAssetId, + getChainFlipSwap, +} from '../utils/helpers' -export const _getTradeQuote = async ( +export const getTradeQuote = async ( input: CommonTradeQuoteInput, deps: SwapperDeps, -): Promise> => { +): Promise => { const { sellAsset, buyAsset, accountNumber, + sendAddress, receiveAddress, sellAmountIncludingProtocolFeesCryptoBaseUnit: sellAmount, - affiliateBps: commissionBps, } = input - if (!isSupportedChainId(sellAsset.chainId)) { + if (accountNumber === undefined) { return Err( makeSwapErrorRight({ - message: `unsupported chainId`, - code: TradeQuoteError.UnsupportedChain, - details: { chainId: sellAsset.chainId }, + message: `accountNumber is required`, + code: TradeQuoteError.UnknownError, }), ) } - if (!isSupportedChainId(buyAsset.chainId)) { + if (sendAddress === undefined) { return Err( makeSwapErrorRight({ - message: `unsupported chainId`, - code: TradeQuoteError.UnsupportedChain, - details: { chainId: sellAsset.chainId }, + message: `sendAddress is required`, + code: TradeQuoteError.UnknownError, }), ) } - if (!isSupportedAssetId(sellAsset.chainId, sellAsset.assetId)) { + if (receiveAddress === undefined) { return Err( makeSwapErrorRight({ - message: `asset '${sellAsset.name}' on chainId '${sellAsset.chainId}' not supported`, - code: TradeQuoteError.UnsupportedTradePair, - details: { chainId: sellAsset.chainId, assetId: sellAsset.assetId }, + message: `receiveAddress is required`, + code: TradeQuoteError.UnknownError, }), ) } - if (!isSupportedAssetId(buyAsset.chainId, buyAsset.assetId)) { - return Err( - makeSwapErrorRight({ - message: `asset '${buyAsset.name}' on chainId '${buyAsset.chainId}' not supported`, - code: TradeQuoteError.UnsupportedTradePair, - details: { chainId: buyAsset.chainId, assetId: buyAsset.assetId }, - }), - ) - } + const maybeTradeQuotes = await getRateOrQuote(input, deps) + + if (maybeTradeQuotes.isErr()) return Err(maybeTradeQuotes.unwrapErr()) const brokerUrl = deps.config.REACT_APP_CHAINFLIP_API_URL const apiKey = deps.config.REACT_APP_CHAINFLIP_API_KEY - const sourceAsset = await getChainFlipIdFromAssetId({ - assetId: sellAsset.assetId, - brokerUrl, - }) - const destinationAsset = await getChainFlipIdFromAssetId({ - assetId: buyAsset.assetId, - brokerUrl, - }) - - // Subtract the BaaS fee to end up at the final displayed commissionBps - let serviceCommission = parseInt(commissionBps) - CHAINFLIP_BAAS_COMMISSION - if (serviceCommission < 0) serviceCommission = 0 - - const maybeQuoteResponse = await chainflipService.get( - `${brokerUrl}/quotes-native` + - `?apiKey=${apiKey}` + - `&sourceAsset=${sourceAsset}` + - `&destinationAsset=${destinationAsset}` + - `&amount=${sellAmount}` + - `&commissionBps=${serviceCommission}`, - ) - - if (maybeQuoteResponse.isErr()) { - const error = maybeQuoteResponse.unwrapErr() - const cause = error.cause as AxiosError - - if ( - cause.message.includes('code 400') && - cause.response!.data.detail.includes('Amount outside asset bounds') - ) { - return Err( - makeSwapErrorRight({ - message: cause.response!.data.detail, - code: TradeQuoteError.SellAmountBelowMinimum, - }), - ) - } - - return Err( - makeSwapErrorRight({ - message: 'Quote request failed', - code: TradeQuoteError.NoRouteFound, - }), - ) - } - - const { data: quoteResponse } = maybeQuoteResponse.unwrap() - - const getFeeData = async () => { - const { chainNamespace } = fromAssetId(sellAsset.assetId) - - switch (chainNamespace) { - case CHAIN_NAMESPACE.Evm: { - const sellAdapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) - const networkFeeCryptoBaseUnit = await getEvmTxFees({ - adapter: sellAdapter, - supportsEIP1559: (input as GetEvmTradeQuoteInput).supportsEIP1559, - sendAsset: sourceAsset, - }) - return { networkFeeCryptoBaseUnit } + const tradeQuotes = maybeTradeQuotes.unwrap() + + // We need to open a deposit channel at this point to attach the swap id to the quote, + // in order to properly fetch the streaming status later + for (const tradeQuote of tradeQuotes) { + for (const step of tradeQuote.steps) { + const sourceAsset = await getChainFlipIdFromAssetId({ + assetId: sellAsset.assetId, + brokerUrl, + }) + const destinationAsset = await getChainFlipIdFromAssetId({ + assetId: buyAsset.assetId, + brokerUrl, + }) + + const minimumPrice = calculateChainflipMinPrice({ + slippageTolerancePercentageDecimal: tradeQuote.slippageTolerancePercentageDecimal, + sellAsset, + buyAsset, + buyAmountAfterFeesCryptoBaseUnit: step.buyAmountAfterFeesCryptoBaseUnit, + sellAmountIncludingProtocolFeesCryptoBaseUnit: + step.sellAmountIncludingProtocolFeesCryptoBaseUnit, + }) + + let serviceCommission = parseInt(tradeQuote.affiliateBps) - CHAINFLIP_BAAS_COMMISSION + if (serviceCommission < 0) serviceCommission = 0 + + const maybeSwapResponse = await getChainFlipSwap({ + brokerUrl, + apiKey, + sourceAsset, + minimumPrice, + destinationAsset, + destinationAddress: input.receiveAddress, + refundAddress: input.sendAddress!, + maxBoostFee: step.chainflipSpecific?.chainflipMaxBoostFee, + numberOfChunks: step.chainflipSpecific?.chainflipNumberOfChunks, + chunkIntervalBlocks: step.chainflipSpecific?.chainflipChunkIntervalBlocks, + commissionBps: serviceCommission, + }) + + if (maybeSwapResponse.isErr()) { + const error = maybeSwapResponse.unwrapErr() + const cause = error.cause as AxiosError + throw Error(cause.response!.data.detail) } - case CHAIN_NAMESPACE.Utxo: { - const sellAdapter = deps.assertGetUtxoChainAdapter(sellAsset.chainId) - const publicKey = (input as GetUtxoTradeQuoteInput).xpub! - const feeData = await getUtxoTxFees({ - sellAmountCryptoBaseUnit: sellAmount, - sellAdapter, - publicKey, - }) - - return feeData - } - - case CHAIN_NAMESPACE.Solana: { - const sellAdapter = deps.assertGetSolanaChainAdapter(sellAsset.chainId) - const getFeeDataInput: GetFeeDataInput = { - // Simulates a self-send, since we don't know the to just yet at this stage - to: input.sendAddress!, - value: sellAmount, - chainSpecific: { - from: input.sendAddress!, - tokenId: - sellAsset.assetId === solAssetId - ? undefined - : fromAssetId(sellAsset.assetId).assetReference, - }, + const { data: swapResponse } = maybeSwapResponse.unwrap() + + if (!swapResponse.id) throw Error('Missing Swap Id') + if (!swapResponse.address) throw Error('Missing Deposit Channel') + + const getFeeData = async () => { + const { chainNamespace } = fromAssetId(sellAsset.assetId) + + // We faked feeData for Solana with a self-send during rates, we can now properly do it on quote time + switch (chainNamespace) { + case CHAIN_NAMESPACE.Solana: { + const sellAdapter = deps.assertGetSolanaChainAdapter(sellAsset.chainId) + const getFeeDataInput: GetFeeDataInput = { + to: input.receiveAddress, + value: sellAmount, + chainSpecific: { + from: input.sendAddress!, + tokenId: + sellAsset.assetId === solAssetId + ? undefined + : fromAssetId(sellAsset.assetId).assetReference, + }, + } + const { fast } = await sellAdapter.getFeeData(getFeeDataInput) + return { + protocolFees: step.feeData.protocolFees, + networkFeeCryptoBaseUnit: fast.txFee, + } as QuoteFeeData + } + + default: + return step.feeData } - const { fast } = await sellAdapter.getFeeData(getFeeDataInput) - return { networkFeeCryptoBaseUnit: fast.txFee } } - default: - throw new Error('Unsupported chainNamespace') - } - } - - const getFeeAsset = (fee: ChainflipBaasQuoteQuoteFee) => { - if (fee.type === 'ingress' || fee.type === 'boost') return sellAsset - - if (fee.type === 'egress') return buyAsset - - if (fee.type === 'liquidity' && fee.asset === sourceAsset) return sellAsset - - if (fee.type === 'liquidity' && fee.asset === destinationAsset) return buyAsset - - if (fee.type === 'liquidity' && fee.asset === 'usdc.eth') return usdcAsset - - if (fee.type === 'network') return usdcAsset - } - - const getProtocolFees = (singleQuoteResponse: ChainflipBaasQuoteQuote) => { - const protocolFees: Record = {} - - for (const fee of singleQuoteResponse.includedFees!) { - if (fee.type === 'broker') continue - - const asset = getFeeAsset(fee)! - if (!(asset.assetId in protocolFees)) { - protocolFees[asset.assetId] = { - amountCryptoBaseUnit: '0', - requiresBalance: false, - asset, + if (!step.chainflipSpecific) + step.chainflipSpecific = { + chainflipSwapId: swapResponse.id, + chainflipDepositAddress: swapResponse.address, } - } - - protocolFees[asset.assetId].amountCryptoBaseUnit = ( - BigInt(protocolFees[asset.assetId].amountCryptoBaseUnit) + BigInt(fee.amountNative!) - ).toString() - } - - return protocolFees - } - - const getQuoteRate = (sellAmountCryptoBaseUnit: string, buyAmountCryptoBaseUnit: string) => { - return getInputOutputRate({ - sellAmountCryptoBaseUnit, - buyAmountCryptoBaseUnit, - sellAsset, - buyAsset, - }) - } - - const getSwapSource = (swapType: string | undefined, isBoosted: boolean) => { - return swapType === CHAINFLIP_REGULAR_QUOTE - ? isBoosted - ? CHAINFLIP_BOOST_SWAP_SOURCE - : CHAINFLIP_SWAP_SOURCE - : isBoosted - ? CHAINFLIP_DCA_BOOST_SWAP_SOURCE - : CHAINFLIP_DCA_SWAP_SOURCE - } - - const quotes: TradeQuote[] = [] - - for (const singleQuoteResponse of quoteResponse) { - const isStreaming = singleQuoteResponse.type === CHAINFLIP_DCA_QUOTE - const feeData = await getFeeData() - - if (isStreaming && !deps.config.REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA) { - // DCA currently disabled - Streaming swap logic is very much tied to THOR currently and will deserve its own PR to generalize - // Even if we manage to get DCA swaps to execute, we wouldn't manage to properly poll with current web THOR-centric arch - continue - } - - if (singleQuoteResponse.boostQuote) { - const boostRate = getQuoteRate( - singleQuoteResponse.boostQuote.ingressAmountNative!, - singleQuoteResponse.boostQuote.egressAmountNative!, - ) - - const boostTradeQuote: TradeQuote = { - id: uuid(), - rate: boostRate, - receiveAddress, - potentialAffiliateBps: commissionBps, - affiliateBps: commissionBps, - isStreaming, - slippageTolerancePercentageDecimal: - input.slippageTolerancePercentageDecimal ?? - getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Chainflip), - steps: [ - { - buyAmountBeforeFeesCryptoBaseUnit: singleQuoteResponse.boostQuote.egressAmountNative!, - buyAmountAfterFeesCryptoBaseUnit: singleQuoteResponse.boostQuote.egressAmountNative!, - sellAmountIncludingProtocolFeesCryptoBaseUnit: - singleQuoteResponse.boostQuote.ingressAmountNative!, - feeData: { - protocolFees: getProtocolFees(singleQuoteResponse.boostQuote), - ...feeData, - }, - rate: boostRate, - source: getSwapSource(singleQuoteResponse.type, true), - buyAsset, - sellAsset, - accountNumber, - allowanceContract: '0x0', // Chainflip does not use contracts - estimatedExecutionTimeMs: - (singleQuoteResponse.boostQuote.estimatedDurationsSeconds!.deposit! + - singleQuoteResponse.boostQuote.estimatedDurationsSeconds!.swap!) * - 1000, - }, - ], - } - - quotes.push(boostTradeQuote) - } - - const rate = getQuoteRate( - singleQuoteResponse.ingressAmountNative!, - singleQuoteResponse.egressAmountNative!, - ) - const tradeQuote: TradeQuote = { - id: uuid(), - rate, - receiveAddress, - potentialAffiliateBps: commissionBps, - affiliateBps: commissionBps, - isStreaming, - slippageTolerancePercentageDecimal: - input.slippageTolerancePercentageDecimal ?? - getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Chainflip), - steps: [ - { - buyAmountBeforeFeesCryptoBaseUnit: singleQuoteResponse.egressAmountNative!, - buyAmountAfterFeesCryptoBaseUnit: singleQuoteResponse.egressAmountNative!, - sellAmountIncludingProtocolFeesCryptoBaseUnit: singleQuoteResponse.ingressAmountNative!, - feeData: { - protocolFees: getProtocolFees(singleQuoteResponse), - ...feeData, - }, - rate, - source: getSwapSource(singleQuoteResponse.type, false), - buyAsset, - sellAsset, - accountNumber, - allowanceContract: '0x0', // Chainflip does not use contracts - all Txs are sends - estimatedExecutionTimeMs: - (singleQuoteResponse.estimatedDurationsSeconds!.deposit! + - singleQuoteResponse.estimatedDurationsSeconds!.swap!) * - 1000, - }, - ], + step.chainflipSpecific.chainflipSwapId = swapResponse.id + step.chainflipSpecific.chainflipDepositAddress = swapResponse.address + step.feeData = await getFeeData() } - - quotes.push(tradeQuote) - } - - return Ok(quotes) -} - -export const getTradeQuote = async ( - input: CommonTradeQuoteInput, - deps: SwapperDeps, -): Promise> => { - const { accountNumber } = input - - if (accountNumber === undefined) { - return Err( - makeSwapErrorRight({ - message: `accountNumber is required`, - code: TradeQuoteError.UnknownError, - }), - ) } - const quotes = await _getTradeQuote(input, deps) - return quotes + return Ok(tradeQuotes) } diff --git a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeRate.ts b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeRate.ts index 0add3599c5a..2825494d73c 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeRate.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeRate.ts @@ -1,20 +1,11 @@ -import type { Result } from '@sniptt/monads' +import type { GetTradeRateInput, SwapperDeps, TradeRateResult } from '../../../types' +import { getRateOrQuote } from '../utils/getRateOrQuote' -import type { - CommonTradeQuoteInput, - GetTradeRateInput, - SwapErrorRight, - SwapperDeps, - TradeRate, -} from '../../../types' -import { _getTradeQuote } from './getTradeQuote' - -// This isn't a mistake. A trade rate *is* a trade quote. Chainflip doesn't really have a notion of a trade quote, -// they do have a notion of a "swap" (which we effectively only use to get the deposit address), which is irrelevant to the notion of quote vs. rate export const getTradeRate = async ( input: GetTradeRateInput, deps: SwapperDeps, -): Promise> => { - const rates = await _getTradeQuote(input as unknown as CommonTradeQuoteInput, deps) - return rates as Result +): Promise => { + // A quote is a rate with guaranteed BIP44 params (account number/sender), + // so we can return quotes which can be used as rates, but not the other way around + return (await getRateOrQuote(input, deps)) as TradeRateResult } diff --git a/packages/swapper/src/swappers/ChainflipSwapper/types.ts b/packages/swapper/src/swappers/ChainflipSwapper/types.ts index da6b02fb57f..89cc43d67ae 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/types.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/types.ts @@ -1,10 +1,11 @@ import type { ChainflipBaasStatusEgress } from './models/ChainflipBaasStatusEgress' +import type { ChainflipBaasStatusSwap } from './models/ChainflipBaasStatusSwap' // Non-exhaustive export type ChainFlipStatus = { status: { - // TODO(gomes): Status polling a la THOR state: 'waiting' | 'receiving' | 'swapping' | 'sending' | 'sent' | 'completed' | 'failed' + swap?: ChainflipBaasStatusSwap swapEgress?: ChainflipBaasStatusEgress } } diff --git a/packages/swapper/src/swappers/ChainflipSwapper/utils/getRateOrQuote.ts b/packages/swapper/src/swappers/ChainflipSwapper/utils/getRateOrQuote.ts new file mode 100644 index 00000000000..1bb5f3f7eb7 --- /dev/null +++ b/packages/swapper/src/swappers/ChainflipSwapper/utils/getRateOrQuote.ts @@ -0,0 +1,393 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { CHAIN_NAMESPACE, fromAssetId, solAssetId } from '@shapeshiftoss/caip' +import type { GetFeeDataInput } from '@shapeshiftoss/chain-adapters' +import type { KnownChainIds } from '@shapeshiftoss/types' +import { assertUnreachable } from '@shapeshiftoss/utils' +import { Err, Ok } from '@sniptt/monads' +import type { AxiosError } from 'axios' +import { v4 as uuid } from 'uuid' + +import { getDefaultSlippageDecimalPercentageForSwapper } from '../../../constants' +import type { + CommonTradeQuoteInput, + GetEvmTradeRateInput, + GetTradeRateInput, + GetUtxoTradeQuoteInput, + ProtocolFee, + SwapperDeps, + SwapSource, + TradeQuote, + TradeQuoteResult, +} from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { getInputOutputRate, createTradeAmountTooSmallErr, makeSwapErrorRight } from '../../../utils' +import { + CHAINFLIP_BAAS_COMMISSION, + CHAINFLIP_BOOST_SWAP_SOURCE, + CHAINFLIP_DCA_BOOST_SWAP_SOURCE, + CHAINFLIP_DCA_QUOTE, + CHAINFLIP_DCA_SWAP_SOURCE, + CHAINFLIP_REGULAR_QUOTE, + CHAINFLIP_SWAP_SOURCE, + usdcAsset, +} from '../constants' +import type { ChainflipBaasQuoteQuote, ChainflipBaasQuoteQuoteFee } from '../models' +import { chainflipService } from './chainflipService' +import { getEvmTxFees } from './getEvmTxFees' +import { getUtxoTxFees } from './getUtxoTxFees' +import { getChainFlipIdFromAssetId, isSupportedAssetId, isSupportedChainId } from './helpers' + +export const getRateOrQuote = async ( + input: GetTradeRateInput | CommonTradeQuoteInput, + deps: SwapperDeps, +): Promise => { + const { + accountNumber, + receiveAddress, + sellAsset, + buyAsset, + sellAmountIncludingProtocolFeesCryptoBaseUnit, + affiliateBps: commissionBps, + } = input + + if (!isSupportedChainId(sellAsset.chainId)) { + return Err( + makeSwapErrorRight({ + message: `unsupported chainId`, + code: TradeQuoteError.UnsupportedChain, + details: { chainId: sellAsset.chainId }, + }), + ) + } + + if (!isSupportedChainId(buyAsset.chainId)) { + return Err( + makeSwapErrorRight({ + message: `unsupported chainId`, + code: TradeQuoteError.UnsupportedChain, + details: { chainId: sellAsset.chainId }, + }), + ) + } + + if (!isSupportedAssetId(sellAsset.chainId, sellAsset.assetId)) { + return Err( + makeSwapErrorRight({ + message: `asset '${sellAsset.name}' on chainId '${sellAsset.chainId}' not supported`, + code: TradeQuoteError.UnsupportedTradePair, + details: { chainId: sellAsset.chainId, assetId: sellAsset.assetId }, + }), + ) + } + + if (!isSupportedAssetId(buyAsset.chainId, buyAsset.assetId)) { + return Err( + makeSwapErrorRight({ + message: `asset '${buyAsset.name}' on chainId '${buyAsset.chainId}' not supported`, + code: TradeQuoteError.UnsupportedTradePair, + details: { chainId: buyAsset.chainId, assetId: buyAsset.assetId }, + }), + ) + } + + const brokerUrl = deps.config.REACT_APP_CHAINFLIP_API_URL + const apiKey = deps.config.REACT_APP_CHAINFLIP_API_KEY + + const sourceAsset = await getChainFlipIdFromAssetId({ + assetId: sellAsset.assetId, + brokerUrl, + }) + const destinationAsset = await getChainFlipIdFromAssetId({ + assetId: buyAsset.assetId, + brokerUrl, + }) + + // Subtract the BaaS fee to end up at the final displayed commissionBps + let serviceCommission = parseInt(commissionBps) - CHAINFLIP_BAAS_COMMISSION + if (serviceCommission < 0) serviceCommission = 0 + + const maybeQuoteResponse = await chainflipService.get( + `${brokerUrl}/quotes-native` + + `?apiKey=${apiKey}` + + `&sourceAsset=${sourceAsset}` + + `&destinationAsset=${destinationAsset}` + + `&amount=${sellAmountIncludingProtocolFeesCryptoBaseUnit}` + + `&commissionBps=${serviceCommission}`, + ) + + if (maybeQuoteResponse.isErr()) { + const error = maybeQuoteResponse.unwrapErr() + const cause = error.cause as AxiosError + + if ( + cause.message.includes('code 400') && + cause.response!.data.detail.includes('Amount outside asset bounds') + ) { + return Err( + createTradeAmountTooSmallErr({ + assetId: sellAsset.assetId, + minAmountCryptoBaseUnit: cause.response!.data.errors.minimalAmountNative[0], + }), + ) + } + + return Err( + makeSwapErrorRight({ + message: 'Quote request failed', + code: TradeQuoteError.NoRouteFound, + }), + ) + } + + const { data: quoteResponse } = maybeQuoteResponse.unwrap() + + const getFeeData = async () => { + const { chainNamespace } = fromAssetId(sellAsset.assetId) + + switch (chainNamespace) { + case CHAIN_NAMESPACE.Evm: { + const sellAdapter = deps.assertGetEvmChainAdapter(sellAsset.chainId) + const networkFeeCryptoBaseUnit = await getEvmTxFees({ + adapter: sellAdapter, + supportsEIP1559: (input as GetEvmTradeRateInput).supportsEIP1559, + sendAsset: sourceAsset, + }) + return { networkFeeCryptoBaseUnit } + } + + case CHAIN_NAMESPACE.Utxo: { + const sellAdapter = deps.assertGetUtxoChainAdapter(sellAsset.chainId) + const pubKey = (input as GetUtxoTradeQuoteInput).xpub + return await getUtxoTxFees({ + sellAmountCryptoBaseUnit: sellAmountIncludingProtocolFeesCryptoBaseUnit, + sellAdapter, + pubKey, + }) + } + + case CHAIN_NAMESPACE.Solana: { + if (!input.sendAddress) return { networkFeeCryptoBaseUnit: undefined } + + const sellAdapter = deps.assertGetSolanaChainAdapter(sellAsset.chainId) + + const getFeeDataInput: GetFeeDataInput = { + // Simulates a self-send, since we don't know the 'to' just yet at this stage + to: input.sendAddress, + value: sellAmountIncludingProtocolFeesCryptoBaseUnit, + chainSpecific: { + from: input.sendAddress, + tokenId: + sellAsset.assetId === solAssetId + ? undefined + : fromAssetId(sellAsset.assetId).assetReference, + }, + } + const { fast } = await sellAdapter.getFeeData(getFeeDataInput) + return { networkFeeCryptoBaseUnit: fast.txFee } + } + + default: + throw new Error('Unsupported chainNamespace') + } + } + + const getFeeAsset = (fee: ChainflipBaasQuoteQuoteFee) => { + if (fee.type === 'ingress' || fee.type === 'boost') return sellAsset + + if (fee.type === 'egress') return buyAsset + + if (fee.type === 'liquidity' && fee.asset === sourceAsset) return sellAsset + + if (fee.type === 'liquidity' && fee.asset === destinationAsset) return buyAsset + + if (fee.type === 'liquidity' && fee.asset === 'usdc.eth') return usdcAsset + + if (fee.type === 'network') return usdcAsset + } + + const getProtocolFees = (singleQuoteResponse: ChainflipBaasQuoteQuote) => { + const protocolFees: Record = {} + + for (const fee of singleQuoteResponse.includedFees!) { + if (fee.type === 'broker') continue + + const asset = getFeeAsset(fee)! + if (!(asset.assetId in protocolFees)) { + protocolFees[asset.assetId] = { + amountCryptoBaseUnit: '0', + requiresBalance: false, + asset, + } + } + + protocolFees[asset.assetId].amountCryptoBaseUnit = ( + BigInt(protocolFees[asset.assetId].amountCryptoBaseUnit) + BigInt(fee.amountNative!) + ).toString() + } + + return protocolFees + } + + const getChainflipQuoteRate = ( + sellAmountCryptoBaseUnit: string, + buyAmountCryptoBaseUnit: string, + ) => { + return getInputOutputRate({ + sellAmountCryptoBaseUnit, + buyAmountCryptoBaseUnit, + sellAsset, + buyAsset, + }) + } + + const getSwapSource = ( + swapType: typeof CHAINFLIP_REGULAR_QUOTE | typeof CHAINFLIP_DCA_QUOTE, + isBoosted: boolean, + ): SwapSource => { + if (swapType === CHAINFLIP_REGULAR_QUOTE) { + return isBoosted ? CHAINFLIP_BOOST_SWAP_SOURCE : CHAINFLIP_SWAP_SOURCE + } + + if (swapType === CHAINFLIP_DCA_QUOTE) { + return isBoosted ? CHAINFLIP_DCA_BOOST_SWAP_SOURCE : CHAINFLIP_DCA_SWAP_SOURCE + } + + return assertUnreachable(swapType) + } + + const getMaxBoostFee = () => { + const { chainNamespace } = fromAssetId(sellAsset.assetId) + + switch (chainNamespace) { + case CHAIN_NAMESPACE.Evm: + return 0 + + case CHAIN_NAMESPACE.Utxo: + return 10 + + case CHAIN_NAMESPACE.Solana: + return 0 + + default: + throw new Error('Unsupported chainNamespace') + } + } + + // A quote is a rate with guaranteed BIP44 params (account number/sender), + // so we can return quotes which can be used as rates, but not the other way around + // The 'input' determines if this is a rate or a quote based on the accountNumber and receiveAddress fields + const ratesOrQuotes: TradeQuote[] = [] + + for (const singleQuoteResponse of quoteResponse) { + const isStreaming = singleQuoteResponse.type === CHAINFLIP_DCA_QUOTE + + if (isStreaming && !deps.config.REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA) continue + + const feeData = await getFeeData() + + if (!singleQuoteResponse.type) throw new Error('Missing quote type') + + if (singleQuoteResponse.boostQuote) { + const boostRate = getChainflipQuoteRate( + singleQuoteResponse.boostQuote.ingressAmountNative!, + singleQuoteResponse.boostQuote.egressAmountNative!, + ) + + const boostTradeRateOrQuote: TradeQuote = { + id: uuid(), + rate: boostRate, + receiveAddress, + potentialAffiliateBps: commissionBps, + affiliateBps: commissionBps, + isStreaming, + slippageTolerancePercentageDecimal: + input.slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Chainflip), + steps: [ + { + buyAmountBeforeFeesCryptoBaseUnit: singleQuoteResponse.boostQuote.egressAmountNative!, + buyAmountAfterFeesCryptoBaseUnit: singleQuoteResponse.boostQuote.egressAmountNative!, + sellAmountIncludingProtocolFeesCryptoBaseUnit: + singleQuoteResponse.boostQuote.ingressAmountNative!, + feeData: { + protocolFees: getProtocolFees(singleQuoteResponse.boostQuote), + ...feeData, + }, + rate: boostRate, + source: getSwapSource(singleQuoteResponse.type, true), + buyAsset, + sellAsset, + accountNumber, + allowanceContract: '0x0', // Chainflip does not use contracts + estimatedExecutionTimeMs: + (singleQuoteResponse.boostQuote.estimatedDurationsSeconds!.deposit! + + singleQuoteResponse.boostQuote.estimatedDurationsSeconds!.swap!) * + 1000, + chainflipSpecific: { + chainflipNumberOfChunks: isStreaming + ? singleQuoteResponse.boostQuote.numberOfChunks ?? undefined + : undefined, + chainflipChunkIntervalBlocks: isStreaming + ? singleQuoteResponse.boostQuote.chunkIntervalBlocks ?? undefined + : undefined, + chainflipMaxBoostFee: getMaxBoostFee(), + }, + }, + ], + } + + ratesOrQuotes.push(boostTradeRateOrQuote) + } + + const rate = getChainflipQuoteRate( + singleQuoteResponse.ingressAmountNative!, + singleQuoteResponse.egressAmountNative!, + ) + + const tradeRateOrQuote: TradeQuote = { + id: uuid(), + rate, + receiveAddress, + potentialAffiliateBps: commissionBps, + affiliateBps: commissionBps, + isStreaming, + slippageTolerancePercentageDecimal: + input.slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Chainflip), + steps: [ + { + buyAmountBeforeFeesCryptoBaseUnit: singleQuoteResponse.egressAmountNative!, + buyAmountAfterFeesCryptoBaseUnit: singleQuoteResponse.egressAmountNative!, + sellAmountIncludingProtocolFeesCryptoBaseUnit: singleQuoteResponse.ingressAmountNative!, + feeData: { + protocolFees: getProtocolFees(singleQuoteResponse), + ...feeData, + }, + rate, + source: getSwapSource(singleQuoteResponse.type, false), + buyAsset, + sellAsset, + accountNumber, + allowanceContract: '0x0', // Chainflip does not use contracts - all Txs are sends + estimatedExecutionTimeMs: + (singleQuoteResponse.estimatedDurationsSeconds!.deposit! + + singleQuoteResponse.estimatedDurationsSeconds!.swap!) * + 1000, + chainflipSpecific: { + chainflipNumberOfChunks: isStreaming + ? singleQuoteResponse.numberOfChunks ?? undefined + : undefined, + chainflipChunkIntervalBlocks: isStreaming + ? singleQuoteResponse.chunkIntervalBlocks ?? undefined + : undefined, + chainflipMaxBoostFee: 0, + }, + }, + ], + } + + ratesOrQuotes.push(tradeRateOrQuote) + } + + return Ok(ratesOrQuotes) +} diff --git a/packages/swapper/src/swappers/ChainflipSwapper/utils/getUtxoTxFees.ts b/packages/swapper/src/swappers/ChainflipSwapper/utils/getUtxoTxFees.ts index e269a1fb7de..fe904f15e94 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/utils/getUtxoTxFees.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/utils/getUtxoTxFees.ts @@ -7,19 +7,25 @@ import type { QuoteFeeData } from '../../../types' type GetUtxoTxFeesInput = { sellAmountCryptoBaseUnit: string sellAdapter: UtxoChainAdapter - publicKey: string + pubKey: string | undefined } export const getUtxoTxFees = async ({ sellAmountCryptoBaseUnit, sellAdapter, - publicKey, + pubKey, }: GetUtxoTxFeesInput): Promise> => { + // Can't do coinselect simulation without a pubkey + if (!pubKey) + return { + networkFeeCryptoBaseUnit: undefined, + } + const getFeeDataInput: GetFeeDataInput = { // One of many vault addresses - just used as a placeholder for the sake of loosely estimating fees - we *need* a *to* address for simulation or this will throw to: 'bc1pfh5x55a3v92klcrdy5yv6yrt7fzr0g929klkdtapp3njfyu4qsyq8qacyf', value: sellAmountCryptoBaseUnit, - chainSpecific: { pubkey: publicKey }, + chainSpecific: { pubkey: pubKey }, } const feeDataOptions = await sellAdapter.getFeeData(getFeeDataInput) diff --git a/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts b/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts index a458c902220..e7a229c124d 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/utils/helpers.ts @@ -26,11 +26,13 @@ type GetChainFlipSwapArgs = ChainFlipBrokerBaseArgs & { sourceAsset: string destinationAsset: string destinationAddress: string - boostFee?: number + maxBoostFee?: number minimumPrice: string refundAddress: string retryDurationInBlocks?: number commissionBps: number + numberOfChunks?: number + chunkIntervalBlocks?: number } type ChainflipAsset = { @@ -93,27 +95,35 @@ export const getChainFlipSwap = ({ sourceAsset, destinationAsset, destinationAddress, - boostFee = 0, + maxBoostFee = 0, minimumPrice, refundAddress, retryDurationInBlocks = 10, commissionBps, + numberOfChunks, + chunkIntervalBlocks = 2, }: GetChainFlipSwapArgs): Promise< Result, SwapErrorRight> -> => - // TODO: For DCA swaps we need to add the numberOfChunks/chunkIntervalBlocks parameters - chainflipService.get( +> => { + let swapUrl = `${brokerUrl}/swap` + - `?apiKey=${apiKey}` + - `&sourceAsset=${sourceAsset}` + - `&destinationAsset=${destinationAsset}` + - `&destinationAddress=${destinationAddress}` + - `&boostFee=${boostFee}` + - `&minimumPrice=${minimumPrice}` + - `&refundAddress=${refundAddress}` + - `&retryDurationInBlocks=${retryDurationInBlocks}` + - `&commissionBps=${commissionBps}`, - ) + `?apiKey=${apiKey}` + + `&sourceAsset=${sourceAsset}` + + `&destinationAsset=${destinationAsset}` + + `&destinationAddress=${destinationAddress}` + + `&boostFee=${maxBoostFee}` + + `&minimumPrice=${minimumPrice}` + + `&refundAddress=${refundAddress}` + + `&retryDurationInBlocks=${retryDurationInBlocks}` + + `&commissionBps=${commissionBps}` + + if (numberOfChunks && chunkIntervalBlocks) { + swapUrl += `&numberOfChunks=${numberOfChunks}` + swapUrl += `&chunkIntervalBlocks=${chunkIntervalBlocks}` + } + + return chainflipService.get(swapUrl) +} const fetchChainFlipAssets = async ({ brokerUrl, diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index ab6eba26200..2943cf771d5 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -286,6 +286,13 @@ export type TradeQuoteStep = { instructions?: TransactionInstruction[] } cowswapQuoteResponse?: OrderQuoteResponse + chainflipSpecific?: { + chainflipSwapId?: number + chainflipDepositAddress?: string + chainflipNumberOfChunks?: number + chainflipChunkIntervalBlocks?: number + chainflipMaxBoostFee?: number + } } export type TradeRateStep = Omit & { accountNumber: undefined } @@ -447,7 +454,7 @@ export type CheckTradeStatusInput = { // a result containing all routes that were successfully generated, or an error in the case where // no routes could be generated -type TradeQuoteResult = Result +export type TradeQuoteResult = Result export type TradeRateResult = Result export type EvmTransactionRequest = { diff --git a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/HopTransactionStep.tsx b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/HopTransactionStep.tsx index 5b1e192c0e9..1c420c905c0 100644 --- a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/HopTransactionStep.tsx +++ b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/HopTransactionStep.tsx @@ -5,6 +5,10 @@ import type { TradeQuoteStep, } from '@shapeshiftoss/swapper' import { SwapperName } from '@shapeshiftoss/swapper' +import { + CHAINFLIP_DCA_BOOST_SWAP_SOURCE, + CHAINFLIP_DCA_SWAP_SOURCE, +} from '@shapeshiftoss/swapper/dist/swappers/ChainflipSwapper/constants' import { THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE, THORCHAIN_STREAM_SWAP_SOURCE, @@ -28,6 +32,8 @@ import { TransactionExecutionState } from 'state/slices/tradeQuoteSlice/types' import { useAppSelector } from 'state/store' import { SwapperIcon } from '../../TradeInput/components/SwapperIcon/SwapperIcon' +import { useChainflipStreamingProgress } from '../hooks/useChainflipStreamingProgress' +import { useThorStreamingProgress } from '../hooks/useThorStreamingProgress' import { useTradeExecution } from '../hooks/useTradeExecution' import { getChainShortName } from '../utils/getChainShortName' import { StatusIcon } from './StatusIcon' @@ -175,16 +181,28 @@ export const HopTransactionStep = ({ ) } - const isThorStreamingSwap = [ + const isStreamingSwap = [ THORCHAIN_STREAM_SWAP_SOURCE, THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE, + CHAINFLIP_DCA_SWAP_SOURCE, + CHAINFLIP_DCA_BOOST_SWAP_SOURCE, ].includes(tradeQuoteStep.source) - if (sellTxHash !== undefined && isThorStreamingSwap) { + if (sellTxHash !== undefined && isStreamingSwap) { + const isThor = + tradeQuoteStep.source === THORCHAIN_STREAM_SWAP_SOURCE || + tradeQuoteStep.source === THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE + const streamingProgress = isThor ? useThorStreamingProgress : useChainflipStreamingProgress + return ( - + ) @@ -192,12 +210,12 @@ export const HopTransactionStep = ({ }, [ isActive, swapTxState, - tradeQuoteStep.source, sellTxHash, handleSignTx, isFetching, tradeQuoteQueryData, translate, + tradeQuoteStep, hopIndex, activeTradeId, ]) diff --git a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StreamingSwap.tsx b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StreamingSwap.tsx index 26c9ec2194f..c00a475c9b8 100644 --- a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StreamingSwap.tsx +++ b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StreamingSwap.tsx @@ -1,26 +1,37 @@ import { WarningIcon } from '@chakra-ui/icons' import { Progress, Stack } from '@chakra-ui/react' -import type { TradeQuote } from '@shapeshiftoss/swapper' +import type { TradeQuote, TradeQuoteStep } from '@shapeshiftoss/swapper' import { useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { Row } from 'components/Row/Row' - -import { useThorStreamingProgress } from '../hooks/useThorStreamingProgress' +import type { StreamingSwapFailedSwap } from 'state/slices/tradeQuoteSlice/types' export type StreamingSwapProps = { + tradeQuoteStep: TradeQuoteStep hopIndex: number activeTradeId: TradeQuote['id'] + useStreamingProgress: (input: { + tradeQuoteStep: TradeQuoteStep + hopIndex: number + confirmedTradeId: TradeQuote['id'] + }) => { + isComplete: boolean + attemptedSwapCount: number + totalSwapCount: number + failedSwaps: StreamingSwapFailedSwap[] + } } export const StreamingSwap = (props: StreamingSwapProps) => { - const { hopIndex, activeTradeId } = props + const { tradeQuoteStep, hopIndex, activeTradeId, useStreamingProgress } = props const translate = useTranslate() - const { totalSwapCount, attemptedSwapCount, isComplete, failedSwaps } = useThorStreamingProgress( + const { totalSwapCount, attemptedSwapCount, isComplete, failedSwaps } = useStreamingProgress({ + tradeQuoteStep, hopIndex, - activeTradeId, - ) + confirmedTradeId: activeTradeId, + }) const isInitializing = useMemo(() => { return !isComplete && totalSwapCount === 0 diff --git a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/types.ts b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/types.ts index 645072e6559..7b5148a0efc 100644 --- a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/types.ts +++ b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/types.ts @@ -17,3 +17,8 @@ export type ThornodeStreamingSwapResponseError = { error: string } export type ThornodeStreamingSwapResponse = | ThornodeStreamingSwapResponseSuccess | ThornodeStreamingSwapResponseError + +export type ChainflipStreamingSwapResponseSuccess = { + executedChunks: number + remainingChunks: number +} diff --git a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/useChainflipStreamingProgress.tsx b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/useChainflipStreamingProgress.tsx new file mode 100644 index 00000000000..c33b3811f23 --- /dev/null +++ b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/useChainflipStreamingProgress.tsx @@ -0,0 +1,169 @@ +import type { TradeQuote, TradeQuoteStep } from '@shapeshiftoss/swapper' +// TODO: Is this import allowed? +import type { ChainFlipStatus } from '@shapeshiftoss/swapper/dist/swappers/ChainflipSwapper/types' +import axios from 'axios' +import { getConfig } from 'config' +import { useEffect, useMemo } from 'react' +import { usePoll } from 'hooks/usePoll/usePoll' +import { selectHopExecutionMetadata } from 'state/slices/tradeQuoteSlice/selectors' +import { tradeQuoteSlice } from 'state/slices/tradeQuoteSlice/tradeQuoteSlice' +import type { + StreamingSwapFailedSwap, + StreamingSwapMetadata, +} from 'state/slices/tradeQuoteSlice/types' +import { useAppDispatch, useAppSelector } from 'state/store' + +import type { ChainflipStreamingSwapResponseSuccess } from './types' + +const POLL_INTERVAL_MILLISECONDS = 5_000 // 5 seconds + +const DEFAULT_STREAMING_SWAP_METADATA: StreamingSwapMetadata = { + attemptedSwapCount: 0, + totalSwapCount: 0, + failedSwaps: [], +} + +const getChainflipStreamingSwap = async ( + swapId: number | undefined, +): Promise => { + if (!swapId) return + + const config = getConfig() + const brokerUrl = config.REACT_APP_CHAINFLIP_API_URL + const apiKey = config.REACT_APP_CHAINFLIP_API_KEY + + const statusResponse = await axios + .get(`${brokerUrl}/status-by-id?apiKey=${apiKey}&swapId=${swapId}`) + .then(response => { + return response.data + }) + .catch(function (error) { + console.log('getChainflipStreamingSwap.getStatusById', error) + return null + }) + + if (!statusResponse) return + + const swapState = statusResponse.status?.state + const dcaStatus = statusResponse.status?.swap?.dca + + if (!dcaStatus) return + + if ( + swapState === 'sending' || + swapState === 'sent' || + swapState === 'completed' || + swapState === 'failed' + ) { + // It's finished! + return { + executedChunks: dcaStatus!.executedChunks!, + remainingChunks: 0, + } + } + + return { + executedChunks: dcaStatus!.executedChunks!, + remainingChunks: dcaStatus!.remainingChunks!, + } +} + +const getStreamingSwapMetadata = ( + data: ChainflipStreamingSwapResponseSuccess, +): StreamingSwapMetadata => { + // When a swap fails on Chainflip, the streaming stops, there are never failed ones + const failedSwaps: StreamingSwapFailedSwap[] = [] + + return { + totalSwapCount: data.executedChunks + data.remainingChunks, + attemptedSwapCount: data.executedChunks ?? 0, + failedSwaps, + } +} + +export const useChainflipStreamingProgress = ({ + tradeQuoteStep, + hopIndex, + confirmedTradeId, +}: { + tradeQuoteStep: TradeQuoteStep + hopIndex: number + confirmedTradeId: TradeQuote['id'] +}): { + isComplete: boolean + attemptedSwapCount: number + totalSwapCount: number + failedSwaps: StreamingSwapFailedSwap[] +} => { + const { poll, cancelPolling } = usePoll() + const dispatch = useAppDispatch() + const hopExecutionMetadataFilter = useMemo(() => { + return { + tradeId: confirmedTradeId, + hopIndex, + } + }, [confirmedTradeId, hopIndex]) + + const { + swap: { sellTxHash, streamingSwap: streamingSwapMeta }, + } = useAppSelector(state => selectHopExecutionMetadata(state, hopExecutionMetadataFilter)) + + const swapId = tradeQuoteStep.chainflipSpecific?.chainflipSwapId + + useEffect(() => { + // don't start polling until we have a tx + if (!sellTxHash) return + + poll({ + fn: async () => { + const updatedStreamingSwapData = await getChainflipStreamingSwap(swapId) + + // no payload at all - must be a failed request - return + if (!updatedStreamingSwapData) return + + // data to update - update + dispatch( + tradeQuoteSlice.actions.setStreamingSwapMeta({ + hopIndex, + streamingSwapMetadata: getStreamingSwapMetadata(updatedStreamingSwapData), + id: confirmedTradeId, + }), + ) + return updatedStreamingSwapData + }, + validate: streamingSwapData => { + if (!streamingSwapData) return false + return streamingSwapData.remainingChunks === 0 + }, + interval: POLL_INTERVAL_MILLISECONDS, + maxAttempts: Infinity, + }) + + // stop polling on dismount + return cancelPolling + }, [ + cancelPolling, + dispatch, + hopIndex, + poll, + sellTxHash, + confirmedTradeId, + swapId, + tradeQuoteStep, + ]) + + const result = useMemo(() => { + const numSuccessfulSwaps = + (streamingSwapMeta?.attemptedSwapCount ?? 0) - (streamingSwapMeta?.failedSwaps?.length ?? 0) + + const isComplete = + streamingSwapMeta !== undefined && numSuccessfulSwaps >= streamingSwapMeta.totalSwapCount + + return { + isComplete, + ...(streamingSwapMeta ?? DEFAULT_STREAMING_SWAP_METADATA), + } + }, [streamingSwapMeta]) + + return result +} diff --git a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/useThorStreamingProgress.tsx b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/useThorStreamingProgress.tsx index 1f6137faf16..6d1ac1347ae 100644 --- a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/useThorStreamingProgress.tsx +++ b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/hooks/useThorStreamingProgress.tsx @@ -1,4 +1,4 @@ -import type { TradeQuote } from '@shapeshiftoss/swapper' +import type { TradeQuote, TradeQuoteStep } from '@shapeshiftoss/swapper' import axios from 'axios' import { getConfig } from 'config' import { useEffect, useMemo, useRef } from 'react' @@ -55,10 +55,14 @@ const getStreamingSwapMetadata = ( } } -export const useThorStreamingProgress = ( - hopIndex: number, - confirmedTradeId: TradeQuote['id'], -): { +export const useThorStreamingProgress = ({ + hopIndex, + confirmedTradeId, +}: { + tradeQuoteStep: TradeQuoteStep + hopIndex: number + confirmedTradeId: TradeQuote['id'] +}): { isComplete: boolean attemptedSwapCount: number totalSwapCount: number