From 4ab323c31dcdf4808626377e1dc54ecaa340296d Mon Sep 17 00:00:00 2001 From: NeOMakinG <14963751+NeOMakinG@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:47:08 +0800 Subject: [PATCH] feat: implement jupiter as a swapper (#8120) --- .env.base | 5 +- .env.dev | 5 +- .env.develop | 4 +- package.json | 1 + packages/caip/src/constants.ts | 2 + .../src/solana/SolanaChainAdapter.ts | 157 ++++++++-- packages/chain-adapters/src/solana/types.ts | 2 + packages/swapper/src/constants.ts | 12 + .../swapperApi/getTradeQuote.ts | 2 +- .../swappers/JupiterSwapper/JupiterSwapper.ts | 32 ++ .../src/swappers/JupiterSwapper/endpoints.ts | 50 ++++ .../swapperApi/getTradeQuote.ts | 273 ++++++++++++++++++ .../JupiterSwapper/swapperApi/getTradeRate.ts | 194 +++++++++++++ .../JupiterSwapper/utils/constants.ts | 16 + .../swappers/JupiterSwapper/utils/helpers.ts | 70 +++++ .../JupiterSwapper/utils/jupiterService.ts | 20 ++ packages/swapper/src/types.ts | 28 +- packages/swapper/src/utils.ts | 35 ++- react-app-rewired/headers/csps/jupiter.ts | 6 + .../components/SwapperIcon/SwapperIcon.tsx | 3 + .../components/SwapperIcon/jupiter-icon.svg | 51 ++++ .../useGetTradeQuotes/useGetTradeRates.tsx | 1 + src/config.ts | 7 +- src/constants/urls.ts | 2 + src/lib/tradeExecution.ts | 1 + src/state/apis/swapper/swapperApi.ts | 11 +- src/state/helpers.ts | 22 +- .../preferencesSlice/preferencesSlice.ts | 12 +- src/state/slices/tradeQuoteSlice/selectors.ts | 4 +- src/test/mocks/store.ts | 5 +- yarn.lock | 8 + 31 files changed, 989 insertions(+), 52 deletions(-) create mode 100644 packages/swapper/src/swappers/JupiterSwapper/JupiterSwapper.ts create mode 100644 packages/swapper/src/swappers/JupiterSwapper/endpoints.ts create mode 100644 packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeQuote.ts create mode 100644 packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeRate.ts create mode 100644 packages/swapper/src/swappers/JupiterSwapper/utils/constants.ts create mode 100644 packages/swapper/src/swappers/JupiterSwapper/utils/helpers.ts create mode 100644 packages/swapper/src/swappers/JupiterSwapper/utils/jupiterService.ts create mode 100644 react-app-rewired/headers/csps/jupiter.ts create mode 100644 src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/jupiter-icon.svg create mode 100644 src/constants/urls.ts diff --git a/.env.base b/.env.base index 6906bbf56d0..9ab070fe2aa 100644 --- a/.env.base +++ b/.env.base @@ -65,13 +65,14 @@ REACT_APP_FEATURE_READ_ONLY_ASSETS=true REACT_APP_FEATURE_SWAPPER_SOLANA=false # Swapper feature flags - other .env files will override these -REACT_APP_FEATURE_CHAINFLIP=false -REACT_APP_FEATURE_CHAINFLIP_DCA=false +REACT_APP_FEATURE_CHAINFLIP_SWAP=false +REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA=false REACT_APP_FEATURE_COWSWAP=true REACT_APP_FEATURE_LIFI_SWAP=true REACT_APP_FEATURE_THOR_SWAP=true REACT_APP_FEATURE_THOR_SWAP_STREAMING_SWAPS=true REACT_APP_FEATURE_ZRX_SWAP=true +REACT_APP_FEATURE_JUPITER_SWAP=false # chat woot REACT_APP_CHATWOOT_TOKEN=jmoXp9BPMSPEYHeJX5YKT15Q diff --git a/.env.dev b/.env.dev index bffd81bdc37..1a04a7112a2 100644 --- a/.env.dev +++ b/.env.dev @@ -4,10 +4,11 @@ REACT_APP_FEATURE_SWAPPER_SOLANA=true # Swapper feature flags -REACT_APP_FEATURE_CHAINFLIP=true -REACT_APP_FEATURE_CHAINFLIP_DCA=false +REACT_APP_FEATURE_CHAINFLIP_SWAP=true +REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA=false REACT_APP_FEATURE_PUBLIC_TRADE_ROUTE=true REACT_APP_FEATURE_LIMIT_ORDERS=true +REACT_APP_FEATURE_JUPITER_SWAP=true # logging REACT_APP_REDUX_WINDOW=false diff --git a/.env.develop b/.env.develop index 8e2651a7df1..45a2c7236f3 100644 --- a/.env.develop +++ b/.env.develop @@ -6,8 +6,8 @@ REACT_APP_FEATURE_SWAPPER_SOLANA=true # Swapper feature flags REACT_APP_FEATURE_LIMIT_ORDERS=true -REACT_APP_FEATURE_CHAINFLIP=true -REACT_APP_FEATURE_CHAINFLIP_DCA=false +REACT_APP_FEATURE_CHAINFLIP_SWAP=true +REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA=false # mixpanel REACT_APP_MIXPANEL_TOKEN=1c1369f6ea23a6404bac41b42817cc4b diff --git a/package.json b/package.json index 404350d5dc8..c576f161639 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@formatjs/intl-numberformat": "^8.10.3", "@formatjs/intl-pluralrules": "^5.2.14", "@json-rpc-tools/utils": "^1.7.6", + "@jup-ag/api": "^6.0.30", "@keepkey/hdwallet-keepkey-rest": "1.40.42", "@keepkey/keepkey-sdk": "0.2.57", "@ledgerhq/hw-transport-webusb": "^6.29.2", diff --git a/packages/caip/src/constants.ts b/packages/caip/src/constants.ts index 6ea029a0147..5e0f705bda7 100644 --- a/packages/caip/src/constants.ts +++ b/packages/caip/src/constants.ts @@ -16,6 +16,8 @@ export const arbitrumAssetId: AssetId = 'eip155:42161/slip44:60' export const arbitrumNovaAssetId: AssetId = 'eip155:42170/slip44:60' export const baseAssetId: AssetId = 'eip155:8453/slip44:60' export const solAssetId: AssetId = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501' +export const wrappedSolAssetId: AssetId = + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:So11111111111111111111111111111111111111112' export const foxatarAssetId: AssetId = 'eip155:137/erc721:0x2e727c425a11ce6b8819b3004db332c12d2af2a2' diff --git a/packages/chain-adapters/src/solana/SolanaChainAdapter.ts b/packages/chain-adapters/src/solana/SolanaChainAdapter.ts index 995cc8bdd09..a2cc85d81af 100644 --- a/packages/chain-adapters/src/solana/SolanaChainAdapter.ts +++ b/packages/chain-adapters/src/solana/SolanaChainAdapter.ts @@ -8,6 +8,7 @@ import { } from '@shapeshiftoss/caip' import type { HDWallet, + SolanaAddressLookupTableAccountInfo, SolanaSignTx, SolanaTxInstruction, SolanaWallet, @@ -22,11 +23,14 @@ import { createTransferInstruction, getAccount, getAssociatedTokenAddressSync, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, TokenAccountNotFoundError, TokenInvalidAccountOwnerError, } from '@solana/spl-token' -import type { TransactionInstruction } from '@solana/web3.js' +import type { AccountInfo, TransactionInstruction } from '@solana/web3.js' import { + AddressLookupTableAccount, ComputeBudgetProgram, Connection, PublicKey, @@ -34,6 +38,7 @@ import { TransactionMessage, VersionedTransaction, } from '@solana/web3.js' +import { isUndefined } from 'lodash' import PQueue from 'p-queue' import type { ChainAdapter as IChainAdapter } from '../api' @@ -62,6 +67,8 @@ import { toAddressNList, toRootDerivationPath } from '../utils' import { assertAddressNotSanctioned } from '../utils/validateAddress' import { microLamportsToLamports } from './utils' +export const svmChainIds = [KnownChainIds.SolanaMainnet] as const + // Maximum compute units allowed for a single solana transaction const MAX_COMPUTE_UNITS = 1400000 @@ -251,6 +258,10 @@ export class ChainAdapter implements IChainAdapter ) } + const addressLookupTableAccountInfos = await this.getAddressLookupTableAccounts( + chainSpecific.addressLookupTableAccounts ?? [], + ) + const txToSign: SignTx = { addressNList: toAddressNList(this.getBIP44Params({ accountNumber })), blockHash: blockhash, @@ -259,6 +270,7 @@ export class ChainAdapter implements IChainAdapter instructions, to: tokenId ? '' : to, value: tokenId ? '' : value, + addressLookupTableAccountInfos, } return { txToSign } @@ -449,8 +461,14 @@ export class ChainAdapter implements IChainAdapter const { to, chainSpecific } = input const { from, tokenId, instructions = [] } = chainSpecific - if (!to) throw new Error('to is required') - if (!input.value) throw new Error('value is required') + const estimationInstructions = [...instructions] + + const addressLookupTableAccounts = await this.getSolanaAddressLookupTableAccountsInfo( + chainSpecific.addressLookupTableAccounts ?? [], + ) + + if (isUndefined(input.to)) throw new Error(`${this.getName()}ChainAdapter: to is required`) + if (!input.value) throw new Error(`${this.getName()}ChainAdapter: value is required`) const value = Number(input.value) @@ -463,9 +481,9 @@ export class ChainAdapter implements IChainAdapter value: input.value, }) - instructions.push(...tokenTransferInstructions) + estimationInstructions.push(...tokenTransferInstructions) } else { - instructions.push( + estimationInstructions.push( SystemProgram.transfer({ fromPubkey: new PublicKey(from), toPubkey: new PublicKey(to), @@ -477,17 +495,19 @@ export class ChainAdapter implements IChainAdapter // Set compute unit limit to the maximum compute units for the purposes of estimating the compute unit cost of a transaction, // ensuring the transaction does not exceed the maximum compute units alotted for a single transaction. - instructions.push(ComputeBudgetProgram.setComputeUnitLimit({ units: MAX_COMPUTE_UNITS })) + estimationInstructions.push( + ComputeBudgetProgram.setComputeUnitLimit({ units: MAX_COMPUTE_UNITS }), + ) // placeholder compute unit price instruction for the purposes of estimating the compute unit cost of a transaction - instructions.push(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 0 })) + estimationInstructions.push(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 0 })) const message = new TransactionMessage({ payerKey: new PublicKey(input.chainSpecific.from), - instructions, + instructions: estimationInstructions, // static block hash as fee estimation replaces the block hash with latest to save us a client side call recentBlockhash: '4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn', - }).compileToV0Message() + }).compileToV0Message(addressLookupTableAccounts) const transaction = new VersionedTransaction(message) @@ -507,10 +527,49 @@ export class ChainAdapter implements IChainAdapter }): Promise { const instructions: TransactionInstruction[] = [] + const { instruction, destinationTokenAccount } = + await this.createAssociatedTokenAccountInstruction({ from, to, tokenId }) + + if (instruction) { + instructions.push(instruction) + } + + instructions.push( + createTransferInstruction( + getAssociatedTokenAddressSync(new PublicKey(tokenId), new PublicKey(from), true), + destinationTokenAccount, + new PublicKey(from), + Number(value), + ), + ) + + return instructions + } + + public async createAssociatedTokenAccountInstruction({ + from, + to, + tokenId, + }: { + from: string + to: string + tokenId: string + }): Promise<{ + instruction?: TransactionInstruction + destinationTokenAccount: PublicKey + }> { + const accountInfo = await this.connection.getAccountInfo(new PublicKey(tokenId)) + + const TOKEN_PROGRAM = + accountInfo?.owner.toString() === TOKEN_2022_PROGRAM_ID.toString() + ? TOKEN_2022_PROGRAM_ID + : TOKEN_PROGRAM_ID + const destinationTokenAccount = getAssociatedTokenAddressSync( new PublicKey(tokenId), new PublicKey(to), true, + TOKEN_PROGRAM, ) // check if destination token account exists and add creation instruction if it doesn't @@ -521,31 +580,27 @@ export class ChainAdapter implements IChainAdapter err instanceof TokenAccountNotFoundError || err instanceof TokenInvalidAccountOwnerError ) { - instructions.push( - createAssociatedTokenAccountInstruction( + return { + instruction: createAssociatedTokenAccountInstruction( // sender pays for creation of the token account new PublicKey(from), destinationTokenAccount, new PublicKey(to), new PublicKey(tokenId), + TOKEN_PROGRAM, ), - ) + destinationTokenAccount, + } } } - instructions.push( - createTransferInstruction( - getAssociatedTokenAddressSync(new PublicKey(tokenId), new PublicKey(from), true), - destinationTokenAccount, - new PublicKey(from), - Number(value), - ), - ) - - return instructions + return { + instruction: undefined, + destinationTokenAccount, + } } - private convertInstruction(instruction: TransactionInstruction): SolanaTxInstruction { + public convertInstruction(instruction: TransactionInstruction): SolanaTxInstruction { return { keys: instruction.keys.map(key => ({ pubkey: key.pubkey.toString(), @@ -557,6 +612,12 @@ export class ChainAdapter implements IChainAdapter } } + public async getTxStatus(tx: unchained.solana.Tx, pubkey: string): Promise { + const parsedTx = await this.parseTx(tx, pubkey) + + return parsedTx.status + } + private async parseTx(tx: unchained.solana.Tx, pubkey: string): Promise { const { address: _, ...parsedTx } = await this.parser.parse(tx, pubkey) @@ -572,4 +633,54 @@ export class ChainAdapter implements IChainAdapter })), } } + + get httpProvider(): unchained.solana.Api { + return this.providers.http + } + + private async getAddressLookupTableAccountsInfo( + addresses: string[], + ): Promise<(AccountInfo | null)[]> { + return await this.connection.getMultipleAccountsInfo(addresses.map(key => new PublicKey(key))) + } + + private async getSolanaAddressLookupTableAccountsInfo( + addresses: string[], + ): Promise { + const addressLookupTableAccountInfos = await this.getAddressLookupTableAccountsInfo(addresses) + + return addressLookupTableAccountInfos.reduce((acc, accountInfo, index) => { + const addressLookupTableAddress = addresses[index] + if (accountInfo) { + const addressLookupTableAccount = new AddressLookupTableAccount({ + key: new PublicKey(addressLookupTableAddress), + state: AddressLookupTableAccount.deserialize( + new Uint8Array(Buffer.from(accountInfo.data)), + ), + }) + acc.push(addressLookupTableAccount) + } + + return acc + }, new Array()) + } + + private async getAddressLookupTableAccounts( + addresses: string[], + ): Promise { + const addressLookupTableAccountInfos = await this.getAddressLookupTableAccountsInfo(addresses) + + return addressLookupTableAccountInfos.reduce((acc, accountInfo, index) => { + const addressLookupTableAddress = addresses[index] + if (accountInfo) { + const addressLookupTableAccount = { + key: addressLookupTableAddress, + data: Buffer.from(accountInfo.data), + } + acc.push(addressLookupTableAccount) + } + + return acc + }, new Array()) + } } diff --git a/packages/chain-adapters/src/solana/types.ts b/packages/chain-adapters/src/solana/types.ts index a898f36d669..53101e7556f 100644 --- a/packages/chain-adapters/src/solana/types.ts +++ b/packages/chain-adapters/src/solana/types.ts @@ -25,12 +25,14 @@ export type BuildTxInput = { computeUnitPrice?: string tokenId?: string instructions?: SolanaTxInstruction[] + addressLookupTableAccounts?: string[] } export type GetFeeDataInput = { from: string tokenId?: string instructions?: TransactionInstruction[] + addressLookupTableAccounts?: string[] } export type FeeData = { diff --git a/packages/swapper/src/constants.ts b/packages/swapper/src/constants.ts index 9a60ff3745d..bb6999ee60f 100644 --- a/packages/swapper/src/constants.ts +++ b/packages/swapper/src/constants.ts @@ -9,6 +9,9 @@ import { chainflipApi } from './swappers/ChainflipSwapper/endpoints' import { cowSwapper } from './swappers/CowSwapper/CowSwapper' import { cowApi } from './swappers/CowSwapper/endpoints' import { COW_SWAP_SUPPORTED_CHAIN_IDS } from './swappers/CowSwapper/utils/constants' +import { jupiterApi } from './swappers/JupiterSwapper/endpoints' +import { jupiterSwapper } from './swappers/JupiterSwapper/JupiterSwapper' +import { JUPITER_SUPPORTED_CHAIN_IDS } from './swappers/JupiterSwapper/utils/constants' import { lifiApi } from './swappers/LifiSwapper/endpoints' import { LIFI_GET_TRADE_QUOTE_POLLING_INTERVAL, @@ -85,6 +88,12 @@ export const swappers: Record< supportedChainIds: CHAINFLIP_SUPPORTED_CHAIN_IDS, pollingInterval: DEFAULT_GET_TRADE_QUOTE_POLLING_INTERVAL, }, + [SwapperName.Jupiter]: { + ...jupiterSwapper, + ...jupiterApi, + supportedChainIds: JUPITER_SUPPORTED_CHAIN_IDS, + pollingInterval: DEFAULT_GET_TRADE_QUOTE_POLLING_INTERVAL, + }, [SwapperName.Test]: undefined, } @@ -96,6 +105,7 @@ const DEFAULT_LIFI_SLIPPAGE_DECIMAL_PERCENTAGE = '0.005' // .5% const DEFAULT_THOR_SLIPPAGE_DECIMAL_PERCENTAGE = '0.01' // 1% const DEFAULT_ARBITRUM_BRIDGE_SLIPPAGE_DECIMAL_PERCENTAGE = '0' // no slippage for Arbitrum Bridge, so no slippage tolerance const DEFAULT_CHAINFLIP_SLIPPAGE_DECIMAL_PERCENTAGE = '0.02' // 2% +const DEFAULT_JUPITER_SLIPPAGE_DECIMAL_PERCENTAGE = '0.01' // 1% export const getDefaultSlippageDecimalPercentageForSwapper = ( swapperName?: SwapperName, @@ -117,6 +127,8 @@ export const getDefaultSlippageDecimalPercentageForSwapper = ( return DEFAULT_ARBITRUM_BRIDGE_SLIPPAGE_DECIMAL_PERCENTAGE case SwapperName.Chainflip: return DEFAULT_CHAINFLIP_SLIPPAGE_DECIMAL_PERCENTAGE + case SwapperName.Jupiter: + return DEFAULT_JUPITER_SLIPPAGE_DECIMAL_PERCENTAGE default: assertUnreachable(swapperName) } diff --git a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts index 4104069b6e7..040b672ed5d 100644 --- a/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts +++ b/packages/swapper/src/swappers/ChainflipSwapper/swapperApi/getTradeQuote.ts @@ -250,7 +250,7 @@ export const _getTradeQuote = async ( const isStreaming = singleQuoteResponse.type === CHAINFLIP_DCA_QUOTE const feeData = await getFeeData() - if (isStreaming && !deps.config.REACT_APP_FEATURE_CHAINFLIP_DCA) { + 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 diff --git a/packages/swapper/src/swappers/JupiterSwapper/JupiterSwapper.ts b/packages/swapper/src/swappers/JupiterSwapper/JupiterSwapper.ts new file mode 100644 index 00000000000..eb1966aead0 --- /dev/null +++ b/packages/swapper/src/swappers/JupiterSwapper/JupiterSwapper.ts @@ -0,0 +1,32 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' + +import { executeSolanaTransaction } from '../..' +import type { BuyAssetBySellIdInput, Swapper } from '../../types' +import { jupiterSupportedChainIds } from './utils/constants' + +export const jupiterSwapper: Swapper = { + executeSolanaTransaction, + + filterAssetIdsBySellable: (assets: Asset[]): Promise => { + return Promise.resolve( + assets + .filter(asset => { + const { chainId } = asset + return jupiterSupportedChainIds.includes(chainId) + }) + .map(asset => asset.assetId), + ) + }, + + filterBuyAssetsBySellAssetId: (input: BuyAssetBySellIdInput): Promise => { + return Promise.resolve( + input.assets + .filter(asset => { + const { chainId } = asset + return jupiterSupportedChainIds.includes(chainId) + }) + .map(asset => asset.assetId), + ) + }, +} diff --git a/packages/swapper/src/swappers/JupiterSwapper/endpoints.ts b/packages/swapper/src/swappers/JupiterSwapper/endpoints.ts new file mode 100644 index 00000000000..70ad7d146d9 --- /dev/null +++ b/packages/swapper/src/swappers/JupiterSwapper/endpoints.ts @@ -0,0 +1,50 @@ +import type { BuildSendApiTxInput } from '@shapeshiftoss/chain-adapters' +import type { SolanaSignTx } from '@shapeshiftoss/hdwallet-core' +import type { KnownChainIds } from '@shapeshiftoss/types' + +import type { GetUnsignedSolanaTransactionArgs, SwapperApi } from '../../types' +import { isSolanaFeeData } from '../../types' +import { checkSolanaSwapStatus, isExecutableTradeQuote, isExecutableTradeStep } from '../../utils' +import { getTradeQuote } from './swapperApi/getTradeQuote' +import { getTradeRate } from './swapperApi/getTradeRate' + +export const jupiterApi: SwapperApi = { + getTradeRate, + getTradeQuote, + getUnsignedSolanaTransaction: async ({ + tradeQuote, + from, + assertGetSolanaChainAdapter, + }: GetUnsignedSolanaTransactionArgs): Promise => { + if (!isExecutableTradeQuote(tradeQuote)) throw Error('Unable to execute trade') + + const step = tradeQuote.steps[0] + + const adapter = assertGetSolanaChainAdapter(step.sellAsset.chainId) + + if (!isExecutableTradeStep(step)) throw Error('Unable to execute step') + + const solanaInstructions = step.jupiterTransactionMetadata?.instructions?.map(instruction => + adapter.convertInstruction(instruction), + ) + + if (!isSolanaFeeData(step.feeData.chainSpecific)) throw Error('Unable to execute step') + + const buildSwapTxInput: BuildSendApiTxInput = { + to: '', + from, + value: '0', + accountNumber: step.accountNumber, + chainSpecific: { + addressLookupTableAccounts: step.jupiterTransactionMetadata?.addressLookupTableAddresses, + instructions: solanaInstructions, + computeUnitLimit: step.feeData.chainSpecific?.computeUnits, + computeUnitPrice: step.feeData.chainSpecific?.priorityFee, + }, + } + + return (await adapter.buildSendApiTransaction(buildSwapTxInput)).txToSign + }, + + checkTradeStatus: checkSolanaSwapStatus, +} diff --git a/packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeQuote.ts b/packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeQuote.ts new file mode 100644 index 00000000000..e050a4b36d3 --- /dev/null +++ b/packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeQuote.ts @@ -0,0 +1,273 @@ +import type { Instruction } from '@jup-ag/api' +import type { AssetId } from '@shapeshiftoss/caip' +import { + ASSET_NAMESPACE, + CHAIN_NAMESPACE, + CHAIN_REFERENCE, + fromAssetId, + solAssetId, + toAssetId, + wrappedSolAssetId, +} from '@shapeshiftoss/caip' +import type { GetFeeDataInput } from '@shapeshiftoss/chain-adapters' +import type { KnownChainIds } from '@shapeshiftoss/types' +import { bnOrZero, convertDecimalPercentageToBasisPoints } from '@shapeshiftoss/utils' +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' +import type { TransactionInstruction } from '@solana/web3.js' +import { PublicKey } from '@solana/web3.js' +import type { AxiosError } from 'axios' +import { v4 as uuid } from 'uuid' + +import { getDefaultSlippageDecimalPercentageForSwapper } from '../../..' +import type { + CommonTradeQuoteInput, + ProtocolFee, + SwapErrorRight, + SwapperDeps, + TradeQuote, +} from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { getRate, makeSwapErrorRight } from '../../../utils' +import { JUPITER_COMPUTE_UNIT_MARGIN_MULTIPLIER } from '../utils/constants' +import { getJupiterPrice, getJupiterSwapInstructions, isSupportedChainId } from '../utils/helpers' + +export const getTradeQuote = async ( + input: CommonTradeQuoteInput, + deps: SwapperDeps, +): Promise> => { + const { + sellAsset, + buyAsset, + affiliateBps, + receiveAddress, + accountNumber, + sendAddress, + sellAmountIncludingProtocolFeesCryptoBaseUnit: sellAmount, + slippageTolerancePercentageDecimal, + } = input + + const { assetsById } = deps + + const jupiterUrl = deps.config.REACT_APP_JUPITER_API_URL + + if (accountNumber === undefined) { + return Err( + makeSwapErrorRight({ + message: `accountNumber is required`, + code: TradeQuoteError.UnknownError, + }), + ) + } + + 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 (!sendAddress) { + return Err( + makeSwapErrorRight({ + message: `sendAddress is required`, + code: TradeQuoteError.UnknownError, + }), + ) + } + + const maybePriceResponse = await getJupiterPrice({ + apiUrl: jupiterUrl, + sourceAsset: sellAsset.assetId === solAssetId ? wrappedSolAssetId : sellAsset.assetId, + destinationAsset: buyAsset.assetId === solAssetId ? wrappedSolAssetId : buyAsset.assetId, + commissionBps: affiliateBps, + amount: sellAmount, + slippageBps: convertDecimalPercentageToBasisPoints( + slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Jupiter), + ).toFixed(), + }) + + if (maybePriceResponse.isErr()) { + return Err( + makeSwapErrorRight({ + message: 'Quote request failed', + code: TradeQuoteError.NoRouteFound, + }), + ) + } + + const { data: quoteResponse } = maybePriceResponse.unwrap() + + const contractAddress = + buyAsset.assetId === solAssetId ? undefined : fromAssetId(buyAsset.assetId).assetReference + + const adapter = deps.assertGetSolanaChainAdapter(sellAsset.chainId) + + const isCrossAccountTrade = receiveAddress ? receiveAddress !== sendAddress : false + + const { instruction: createTokenAccountInstruction, destinationTokenAccount } = + contractAddress && isCrossAccountTrade + ? await adapter.createAssociatedTokenAccountInstruction({ + from: sendAddress, + to: receiveAddress!, + tokenId: contractAddress, + }) + : { instruction: undefined, destinationTokenAccount: undefined } + + const maybeSwapResponse = await getJupiterSwapInstructions({ + apiUrl: jupiterUrl, + fromAddress: sendAddress, + toAddress: isCrossAccountTrade ? destinationTokenAccount?.toString() : undefined, + rawQuote: quoteResponse, + // Shared account is not supported for simple AMMs + useSharedAccounts: quoteResponse.routePlan.length > 1 && isCrossAccountTrade ? true : false, + }) + + if (maybeSwapResponse.isErr()) { + const error = maybeSwapResponse.unwrapErr() + const cause = error.cause as AxiosError + throw Error(cause.response!.data.detail) + } + + const { data: swapResponse } = maybeSwapResponse.unwrap() + + const convertJupiterInstruction = (instruction: Instruction): TransactionInstruction => ({ + ...instruction, + keys: instruction.accounts.map(account => ({ + ...account, + pubkey: new PublicKey(account.pubkey), + })), + data: Buffer.from(instruction.data, 'base64'), + programId: new PublicKey(instruction.programId), + }) + + const instructions: TransactionInstruction[] = [ + ...swapResponse.setupInstructions.map(convertJupiterInstruction), + convertJupiterInstruction(swapResponse.swapInstruction), + ] + + if (createTokenAccountInstruction) { + instructions.unshift(createTokenAccountInstruction) + } + + if (swapResponse.cleanupInstruction) { + instructions.push(convertJupiterInstruction(swapResponse.cleanupInstruction)) + } + + const getFeeData = async () => { + const sellAdapter = deps.assertGetSolanaChainAdapter(sellAsset.chainId) + const getFeeDataInput: GetFeeDataInput = { + to: '', + value: '0', + chainSpecific: { + from: sendAddress, + addressLookupTableAccounts: swapResponse.addressLookupTableAddresses, + instructions, + }, + } + const feeData = await sellAdapter.getFeeData(getFeeDataInput) + return { + txFee: feeData.fast.txFee, + chainSpecific: { + computeUnits: bnOrZero(feeData.fast.chainSpecific.computeUnits) + .times(JUPITER_COMPUTE_UNIT_MARGIN_MULTIPLIER) + .toFixed(0), + priorityFee: feeData.fast.chainSpecific.priorityFee, + }, + } + } + + const protocolFees: Record = quoteResponse.routePlan.reduce( + (acc, route) => { + const feeAssetId = toAssetId({ + assetReference: route.swapInfo.feeMint, + assetNamespace: ASSET_NAMESPACE.splToken, + chainNamespace: CHAIN_NAMESPACE.Solana, + chainReference: CHAIN_REFERENCE.SolanaMainnet, + }) + const feeAsset = assetsById[feeAssetId] + + // If we can't find the feeAsset, we can't provide a protocol fee to display + // But these fees exists at protocol level, it's mostly to make TS happy as we should have the market data and assets + if (!feeAsset) return acc + + acc[feeAssetId] = { + requiresBalance: false, + amountCryptoBaseUnit: bnOrZero(route.swapInfo.feeAmount).toFixed(0), + asset: feeAsset, + } + + return acc + }, + {} as Record, + ) + + const getQuoteRate = (sellAmountCryptoBaseUnit: string, buyAmountCryptoBaseUnit: string) => { + return getRate({ + sellAmountCryptoBaseUnit, + buyAmountCryptoBaseUnit, + sellAsset, + buyAsset, + }) + } + + const quotes: TradeQuote[] = [] + + const feeData = await getFeeData() + + const rate = getQuoteRate(quoteResponse.inAmount, quoteResponse.outAmount) + + const tradeQuote: TradeQuote = { + id: uuid(), + rate, + potentialAffiliateBps: affiliateBps, + affiliateBps, + receiveAddress, + slippageTolerancePercentageDecimal: + slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Jupiter), + steps: [ + { + accountNumber, + buyAmountBeforeFeesCryptoBaseUnit: quoteResponse.outAmount, + buyAmountAfterFeesCryptoBaseUnit: quoteResponse.outAmount, + sellAmountIncludingProtocolFeesCryptoBaseUnit: quoteResponse.inAmount, + jupiterQuoteResponse: quoteResponse, + jupiterTransactionMetadata: { + addressLookupTableAddresses: swapResponse.addressLookupTableAddresses, + instructions, + }, + feeData: { + protocolFees, + networkFeeCryptoBaseUnit: feeData.txFee, + chainSpecific: feeData.chainSpecific, + }, + rate, + source: SwapperName.Jupiter, + buyAsset, + sellAsset, + allowanceContract: '0x0', + // Swap are so fasts on solana that times are under 100ms displaying 0 or very small amount of time is not user friendly + estimatedExecutionTimeMs: undefined, + }, + ], + } + + quotes.push(tradeQuote) + + return Ok(quotes) +} diff --git a/packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeRate.ts b/packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeRate.ts new file mode 100644 index 00000000000..27f8f2838fc --- /dev/null +++ b/packages/swapper/src/swappers/JupiterSwapper/swapperApi/getTradeRate.ts @@ -0,0 +1,194 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { + ASSET_NAMESPACE, + CHAIN_NAMESPACE, + CHAIN_REFERENCE, + fromAssetId, + solAssetId, + toAssetId, + wrappedSolAssetId, +} from '@shapeshiftoss/caip' +import type { GetFeeDataInput } from '@shapeshiftoss/chain-adapters' +import type { KnownChainIds } from '@shapeshiftoss/types' +import { bnOrZero, convertDecimalPercentageToBasisPoints } from '@shapeshiftoss/utils' +import type { Result } from '@sniptt/monads' +import { Err, Ok } from '@sniptt/monads' +import { v4 as uuid } from 'uuid' + +import { getDefaultSlippageDecimalPercentageForSwapper } from '../../..' +import type { + GetTradeRateInput, + ProtocolFee, + SwapErrorRight, + SwapperDeps, + TradeRate, +} from '../../../types' +import { SwapperName, TradeQuoteError } from '../../../types' +import { getRate, makeSwapErrorRight } from '../../../utils' +import { SOLANA_RANDOM_ADDRESS } from '../utils/constants' +import { getJupiterPrice, isSupportedChainId } from '../utils/helpers' + +export const getTradeRate = async ( + input: GetTradeRateInput, + deps: SwapperDeps, +): Promise> => { + const { + sellAsset, + buyAsset, + sellAmountIncludingProtocolFeesCryptoBaseUnit: sellAmount, + affiliateBps, + receiveAddress, + accountNumber, + slippageTolerancePercentageDecimal, + } = input + + const { assetsById } = deps + + const jupiterUrl = deps.config.REACT_APP_JUPITER_API_URL + + 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 (buyAsset.assetId === wrappedSolAssetId || sellAsset.assetId === wrappedSolAssetId) { + return Err( + makeSwapErrorRight({ + message: `Unsupported trade pair`, + code: TradeQuoteError.UnsupportedTradePair, + }), + ) + } + + const maybePriceResponse = await getJupiterPrice({ + apiUrl: jupiterUrl, + sourceAsset: sellAsset.assetId === solAssetId ? wrappedSolAssetId : sellAsset.assetId, + destinationAsset: buyAsset.assetId === solAssetId ? wrappedSolAssetId : buyAsset.assetId, + commissionBps: affiliateBps, + amount: sellAmount, + slippageBps: convertDecimalPercentageToBasisPoints( + slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Jupiter), + ).toFixed(), + }) + + if (maybePriceResponse.isErr()) { + return Err( + makeSwapErrorRight({ + message: 'Quote request failed', + code: TradeQuoteError.NoRouteFound, + }), + ) + } + + const { data: quoteResponse } = maybePriceResponse.unwrap() + + const getFeeData = async () => { + const sellAdapter = deps.assertGetSolanaChainAdapter(sellAsset.chainId) + const getFeeDataInput: GetFeeDataInput = { + // used as a placeholder for the sake of loosely estimating fees + to: SOLANA_RANDOM_ADDRESS, + value: sellAmount, + chainSpecific: { + from: SOLANA_RANDOM_ADDRESS, + tokenId: + sellAsset.assetId === solAssetId + ? undefined + : fromAssetId(sellAsset.assetId).assetReference, + }, + } + const { fast } = await sellAdapter.getFeeData(getFeeDataInput) + return { networkFeeCryptoBaseUnit: fast.txFee } + } + + const protocolFees: Record = quoteResponse.routePlan.reduce( + (acc, route) => { + const feeAssetId = toAssetId({ + assetReference: route.swapInfo.feeMint, + assetNamespace: ASSET_NAMESPACE.splToken, + chainNamespace: CHAIN_NAMESPACE.Solana, + chainReference: CHAIN_REFERENCE.SolanaMainnet, + }) + const feeAsset = assetsById[feeAssetId] + + // If we can't find the feeAsset, we can't provide a protocol fee to display + // But these fees exists at protocol level, it's mostly to make TS happy as we should have the market data and assets + if (!feeAsset) return acc + + acc[feeAssetId] = { + requiresBalance: false, + amountCryptoBaseUnit: bnOrZero(route.swapInfo.feeAmount).toFixed(0), + asset: feeAsset, + } + + return acc + }, + {} as Record, + ) + + const getQuoteRate = (sellAmountCryptoBaseUnit: string, buyAmountCryptoBaseUnit: string) => { + return getRate({ + sellAmountCryptoBaseUnit, + buyAmountCryptoBaseUnit, + sellAsset, + buyAsset, + }) + } + + const rates: TradeRate[] = [] + + const feeData = await getFeeData() + + const rate = getQuoteRate(quoteResponse.inAmount, quoteResponse.outAmount) + + const tradeRate: TradeRate = { + id: uuid(), + rate, + receiveAddress, + potentialAffiliateBps: affiliateBps, + affiliateBps, + accountNumber, + slippageTolerancePercentageDecimal: + slippageTolerancePercentageDecimal ?? + getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Jupiter), + steps: [ + { + buyAmountBeforeFeesCryptoBaseUnit: quoteResponse.outAmount, + buyAmountAfterFeesCryptoBaseUnit: quoteResponse.outAmount, + sellAmountIncludingProtocolFeesCryptoBaseUnit: quoteResponse.inAmount, + jupiterQuoteResponse: quoteResponse, + feeData: { + protocolFees, + ...feeData, + }, + rate, + source: SwapperName.Jupiter, + buyAsset, + sellAsset, + accountNumber, + allowanceContract: '0x0', + // Swap are so fasts on solana that times are under 100ms displaying 0 or very small amount of time is not user friendly + estimatedExecutionTimeMs: undefined, + }, + ], + } + + rates.push(tradeRate) + + return Ok(rates) +} diff --git a/packages/swapper/src/swappers/JupiterSwapper/utils/constants.ts b/packages/swapper/src/swappers/JupiterSwapper/utils/constants.ts new file mode 100644 index 00000000000..a196d3a652c --- /dev/null +++ b/packages/swapper/src/swappers/JupiterSwapper/utils/constants.ts @@ -0,0 +1,16 @@ +import type { ChainId } from '@shapeshiftoss/caip' +import { KnownChainIds } from '@shapeshiftoss/types' + +import type { SupportedChainIds } from '../../../types' + +export const jupiterSupportedChainIds: ChainId[] = [KnownChainIds.SolanaMainnet] + +export const JUPITER_SUPPORTED_CHAIN_IDS: SupportedChainIds = { + sell: jupiterSupportedChainIds, + buy: jupiterSupportedChainIds, +} + +export const SOLANA_RANDOM_ADDRESS = '2zHKF6tqam3tnNFPK2E9nBDkV7GMXnvdJautmzqQdn8A' + +// Jupiter use 40% as a compute unit margin while calculating them, some TX reverts without this +export const JUPITER_COMPUTE_UNIT_MARGIN_MULTIPLIER = 1.4 diff --git a/packages/swapper/src/swappers/JupiterSwapper/utils/helpers.ts b/packages/swapper/src/swappers/JupiterSwapper/utils/helpers.ts new file mode 100644 index 00000000000..eae52dceb79 --- /dev/null +++ b/packages/swapper/src/swappers/JupiterSwapper/utils/helpers.ts @@ -0,0 +1,70 @@ +import type { QuoteResponse, SwapInstructionsResponse } from '@jup-ag/api' +import type { ChainId } from '@shapeshiftoss/caip' +import { fromAssetId } from '@shapeshiftoss/caip' +import type { KnownChainIds } from '@shapeshiftoss/types' +import type { Result } from '@sniptt/monads' +import type { AxiosResponse } from 'axios' + +import type { SwapErrorRight } from '../../../types' +import { jupiterSupportedChainIds } from './constants' +import { jupiterService } from './jupiterService' + +export const isSupportedChainId = (chainId: ChainId): chainId is KnownChainIds.SolanaMainnet => { + return jupiterSupportedChainIds.includes(chainId) +} + +const JUPITER_TRANSACTION_MAX_ACCOUNTS = 54 + +type GetJupiterQuoteArgs = { + apiUrl: string + sourceAsset: string + destinationAsset: string + commissionBps: string + amount: string + slippageBps: string +} + +type GetJupiterSwapArgs = { + apiUrl: string + fromAddress: string + rawQuote: unknown + toAddress?: string + useSharedAccounts: boolean +} + +export const getJupiterPrice = ({ + apiUrl, + sourceAsset, + destinationAsset, + commissionBps, + amount, + slippageBps, +}: GetJupiterQuoteArgs): Promise, SwapErrorRight>> => + jupiterService.get( + `${apiUrl}/quote` + + `?inputMint=${fromAssetId(sourceAsset).assetReference}` + + `&outputMint=${fromAssetId(destinationAsset).assetReference}` + + `&amount=${amount}` + + `&slippageBps=${slippageBps}` + + `&maxAccounts=${JUPITER_TRANSACTION_MAX_ACCOUNTS}` + + `&platformFeeBps=${commissionBps}`, + ) + +// @TODO: Add DAO's fee account +export const getJupiterSwapInstructions = ({ + apiUrl, + fromAddress, + toAddress, + rawQuote, + useSharedAccounts, +}: GetJupiterSwapArgs): Promise< + Result, SwapErrorRight> +> => + jupiterService.post(`${apiUrl}/swap-instructions`, { + userPublicKey: fromAddress, + destinationTokenAccount: toAddress, + useSharedAccounts, + quoteResponse: rawQuote, + dynamicComputeUnitLimit: true, + prioritizationFeeLamports: 'auto', + }) diff --git a/packages/swapper/src/swappers/JupiterSwapper/utils/jupiterService.ts b/packages/swapper/src/swappers/JupiterSwapper/utils/jupiterService.ts new file mode 100644 index 00000000000..2657249d8a6 --- /dev/null +++ b/packages/swapper/src/swappers/JupiterSwapper/utils/jupiterService.ts @@ -0,0 +1,20 @@ +import { SwapperName } from '../../../types' +import { createCache, makeSwapperAxiosServiceMonadic } from '../../../utils' + +const maxAgeMillis = 15 * 1000 +const cachedUrls: string[] = [] + +const axiosConfig = { + timeout: 10000, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, +} + +const jupiterServiceBase = createCache(maxAgeMillis, cachedUrls, axiosConfig) + +export const jupiterService = makeSwapperAxiosServiceMonadic( + jupiterServiceBase, + SwapperName.Jupiter, +) diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index ef9dff37aa7..ab6eba26200 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -1,3 +1,4 @@ +import type { QuoteResponse } from '@jup-ag/api' import type { StdSignDoc } from '@keplr-wallet/types' import type { AccountId, AssetId, ChainId, Nominal } from '@shapeshiftoss/caip' import type { @@ -22,6 +23,7 @@ import type { import type { OrderQuoteResponse } from '@shapeshiftoss/types/dist/cowSwap' import type { evm, TxStatus } from '@shapeshiftoss/unchained-client' import type { Result } from '@sniptt/monads' +import type { TransactionInstruction } from '@solana/web3.js' import type { TypedData } from 'eip-712' import type { InterpolationOptions } from 'node-polyglot' import type { Address } from 'viem' @@ -51,7 +53,8 @@ export type SwapperConfig = { REACT_APP_ZRX_BASE_URL: string REACT_APP_CHAINFLIP_API_KEY: string REACT_APP_CHAINFLIP_API_URL: string - REACT_APP_FEATURE_CHAINFLIP_DCA: boolean + REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA: boolean + REACT_APP_JUPITER_API_URL: string } export enum SwapperName { @@ -63,6 +66,7 @@ export enum SwapperName { ArbitrumBridge = 'Arbitrum Bridge', Portals = 'Portals', Chainflip = 'Chainflip', + Jupiter = 'Jupiter', } export type SwapSource = SwapperName | `${SwapperName} • ${string}` @@ -113,6 +117,11 @@ export type CosmosSdkFeeData = { estimatedGasCryptoBaseUnit: string } +export type SolanaFeeData = { + computeUnits: string + priorityFee: string +} + export type AmountDisplayMeta = { amountCryptoBaseUnit: string asset: Partial & Pick @@ -123,7 +132,13 @@ export type ProtocolFee = { requiresBalance: boolean } & AmountDisplayMeta export type QuoteFeeData = { networkFeeCryptoBaseUnit: string | undefined // fee paid to the network from the fee asset (undefined if unknown) protocolFees: PartialRecord // fee(s) paid to the protocol(s) - chainSpecific?: UtxoFeeData | CosmosSdkFeeData + chainSpecific?: UtxoFeeData | CosmosSdkFeeData | SolanaFeeData +} + +export const isSolanaFeeData = ( + chainSpecific: QuoteFeeData['chainSpecific'], +): chainSpecific is SolanaFeeData => { + return Boolean(chainSpecific && 'priorityFee' in chainSpecific) } export type BuyAssetBySellIdInput = { @@ -221,7 +236,6 @@ export type UtxoSwapperDeps = { assertGetUtxoChainAdapter: (chainId: ChainId) => export type CosmosSdkSwapperDeps = { assertGetCosmosSdkChainAdapter: (chainId: ChainId) => CosmosSdkChainAdapter } - export type SolanaSwapperDeps = { assertGetSolanaChainAdapter: (chainId: ChainId) => SolanaChainAdapter } @@ -266,6 +280,11 @@ export type TradeQuoteStep = { value: string gasLimit: string } + jupiterQuoteResponse?: QuoteResponse + jupiterTransactionMetadata?: { + addressLookupTableAddresses: string[] + instructions?: TransactionInstruction[] + } cowswapQuoteResponse?: OrderQuoteResponse } @@ -423,7 +442,8 @@ export type CheckTradeStatusInput = { config: SwapperConfig } & EvmSwapperDeps & UtxoSwapperDeps & - CosmosSdkSwapperDeps + CosmosSdkSwapperDeps & + SolanaSwapperDeps // a result containing all routes that were successfully generated, or an error in the case where // no routes could be generated diff --git a/packages/swapper/src/utils.ts b/packages/swapper/src/utils.ts index b39f32bc9fd..cc0f76dfedc 100644 --- a/packages/swapper/src/utils.ts +++ b/packages/swapper/src/utils.ts @@ -1,6 +1,7 @@ import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' +import { fromAccountId, fromAssetId, solanaChainId } from '@shapeshiftoss/caip' import type { EvmChainAdapter } from '@shapeshiftoss/chain-adapters' +import type { ChainAdapter as SolanaChainAdapter } from '@shapeshiftoss/chain-adapters/dist/solana/SolanaChainAdapter' import type { SolanaSignTx } from '@shapeshiftoss/hdwallet-core' import type { Asset } from '@shapeshiftoss/types' import { evm, TxStatus } from '@shapeshiftoss/unchained-client' @@ -320,3 +321,35 @@ export const isToken = (assetId: AssetId) => { } export const isExecutableTradeStep = (step: TradeQuoteStep): step is ExecutableTradeStep => step.accountNumber !== undefined + +export const checkSolanaSwapStatus = async ({ + txHash, + accountId, + assertGetSolanaChainAdapter, +}: { + txHash: string + accountId: AccountId | undefined + assertGetSolanaChainAdapter: (chainId: ChainId) => SolanaChainAdapter +}): Promise<{ + status: TxStatus + buyTxHash: string | undefined + message: string | [string, InterpolationOptions] | undefined +}> => { + try { + if (!accountId) throw new Error('Missing accountId') + + const account = fromAccountId(accountId).account + const adapter = assertGetSolanaChainAdapter(solanaChainId) + const tx = await adapter.httpProvider.getTransaction({ txid: txHash }) + const status = await adapter.getTxStatus(tx, account) + + return { + status, + buyTxHash: txHash, + message: undefined, + } + } catch (e) { + console.error(e) + return createDefaultStatusResponse(txHash) + } +} diff --git a/react-app-rewired/headers/csps/jupiter.ts b/react-app-rewired/headers/csps/jupiter.ts new file mode 100644 index 00000000000..d6d9d705cc4 --- /dev/null +++ b/react-app-rewired/headers/csps/jupiter.ts @@ -0,0 +1,6 @@ +import { JUPITER_BASE_API_URL } from '../../../src/constants/urls' +import type { Csp } from '../types' + +export const csp: Csp = { + 'connect-src': [JUPITER_BASE_API_URL], +} diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx index a9a1a24e72b..a62cdd891b5 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/SwapperIcon.tsx @@ -8,6 +8,7 @@ import ZrxIcon from './0x-icon.png' import ArbitrumBridgeIcon from './arbitrum-bridge-icon.png' import ChainflipIcon from './chainflip-icon.png' import CowIcon from './cow-icon.png' +import JupiterIcon from './jupiter-icon.svg' import LiFiIcon from './lifi-icon.png' import PortalsIcon from './portals-icon.png' import THORChainIcon from './thorchain-icon.png' @@ -35,6 +36,8 @@ export const SwapperIcon = ({ return PortalsIcon case SwapperName.Chainflip: return ChainflipIcon + case SwapperName.Jupiter: + return JupiterIcon case SwapperName.Test: return '' default: diff --git a/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/jupiter-icon.svg b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/jupiter-icon.svg new file mode 100644 index 00000000000..382f9eadf12 --- /dev/null +++ b/src/components/MultiHopTrade/components/TradeInput/components/SwapperIcon/jupiter-icon.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeRates.tsx b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeRates.tsx index 15087e163de..f9be0e39b44 100644 --- a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeRates.tsx +++ b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeRates.tsx @@ -273,6 +273,7 @@ export const useGetTradeRates = () => { useGetSwapperTradeQuoteOrRate(getTradeQuoteArgs(SwapperName.Thorchain)) useGetSwapperTradeQuoteOrRate(getTradeQuoteArgs(SwapperName.Zrx)) useGetSwapperTradeQuoteOrRate(getTradeQuoteArgs(SwapperName.Chainflip)) + useGetSwapperTradeQuoteOrRate(getTradeQuoteArgs(SwapperName.Jupiter)) // true if any debounce, input or swapper is fetching const isAnyTradeQuoteLoading = useAppSelector(selectIsAnyTradeQuoteLoading) diff --git a/src/config.ts b/src/config.ts index 9c25e43f13a..f97f697b353 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import { JUPITER_API_URL } from 'constants/urls' import * as envalid from 'envalid' import { bool } from 'envalid' import forEach from 'lodash/forEach' @@ -174,12 +175,14 @@ const validators = { REACT_APP_FEATURE_FOX_PAGE_GOVERNANCE: bool({ default: false }), REACT_APP_FEATURE_LIMIT_ORDERS: bool({ default: false }), REACT_APP_ZRX_BASE_URL: url(), - REACT_APP_FEATURE_CHAINFLIP: bool({ default: false }), + REACT_APP_FEATURE_CHAINFLIP_SWAP: bool({ default: false }), + REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA: bool({ default: false }), REACT_APP_FEATURE_SWAPPER_SOLANA: bool({ default: false }), - REACT_APP_FEATURE_CHAINFLIP_DCA: bool({ default: false }), REACT_APP_CHAINFLIP_API_KEY: str(), REACT_APP_CHAINFLIP_API_URL: url(), REACT_APP_FEATURE_THOR_FREE_FEES: bool({ default: false }), + REACT_APP_FEATURE_JUPITER_SWAP: bool({ default: false }), + REACT_APP_JUPITER_API_URL: url({ default: JUPITER_API_URL }), } function reporter({ errors }: envalid.ReporterOptions) { diff --git a/src/constants/urls.ts b/src/constants/urls.ts new file mode 100644 index 00000000000..650668c44bd --- /dev/null +++ b/src/constants/urls.ts @@ -0,0 +1,2 @@ +export const JUPITER_BASE_API_URL = 'https://quote-api.jup.ag' +export const JUPITER_API_URL = `${JUPITER_BASE_API_URL}/v6` diff --git a/src/lib/tradeExecution.ts b/src/lib/tradeExecution.ts index db55b676e5b..07a622efaa4 100644 --- a/src/lib/tradeExecution.ts +++ b/src/lib/tradeExecution.ts @@ -104,6 +104,7 @@ export class TradeExecution { assertGetEvmChainAdapter, assertGetUtxoChainAdapter, assertGetCosmosSdkChainAdapter, + assertGetSolanaChainAdapter, fetchIsSmartContractAddressQuery, }) diff --git a/src/state/apis/swapper/swapperApi.ts b/src/state/apis/swapper/swapperApi.ts index 14f4210b303..4ba2c5a219a 100644 --- a/src/state/apis/swapper/swapperApi.ts +++ b/src/state/apis/swapper/swapperApi.ts @@ -1,6 +1,6 @@ import { createApi } from '@reduxjs/toolkit/dist/query/react' import type { AssetId, ChainId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' +import { fromAssetId, solAssetId } from '@shapeshiftoss/caip' import type { GetTradeRateInput, SwapperConfig, SwapperDeps } from '@shapeshiftoss/swapper' import { getSupportedBuyAssetIds, @@ -56,11 +56,16 @@ export const swapperApi = createApi({ quoteOrRate, } = tradeQuoteInput + const isSolBuyAssetId = buyAsset.assetId === solAssetId const isCrossAccountTrade = Boolean(sendAddress && receiveAddress) && sendAddress?.toLowerCase() !== receiveAddress?.toLowerCase() const featureFlags: FeatureFlags = selectFeatureFlags(state) - const isSwapperEnabled = getEnabledSwappers(featureFlags, isCrossAccountTrade)[swapperName] + const isSwapperEnabled = getEnabledSwappers( + featureFlags, + isCrossAccountTrade, + isSolBuyAssetId, + )[swapperName] if (!isSwapperEnabled) return { data: {} } @@ -264,7 +269,7 @@ export const swapperApi = createApi({ const state = getState() as ReduxState const featureFlags = selectFeatureFlags(state) - const enabledSwappers = getEnabledSwappers(featureFlags, false) + const enabledSwappers = getEnabledSwappers(featureFlags, false, false) const assets = selectAssets(state) const sellAsset = selectInputSellAsset(state) const swapperConfig: SwapperConfig = getConfig() diff --git a/src/state/helpers.ts b/src/state/helpers.ts index 2a77bb7a9f8..c03af44fffd 100644 --- a/src/state/helpers.ts +++ b/src/state/helpers.ts @@ -8,6 +8,7 @@ export const isCrossAccountTradeSupported = (swapperName: SwapperName) => { case SwapperName.Thorchain: case SwapperName.LIFI: case SwapperName.Chainflip: + case SwapperName.Jupiter: return true case SwapperName.Zrx: case SwapperName.CowSwap: @@ -22,8 +23,18 @@ export const isCrossAccountTradeSupported = (swapperName: SwapperName) => { } export const getEnabledSwappers = ( - { Chainflip, Portals, LifiSwap, ThorSwap, ZrxSwap, ArbitrumBridge, Cowswap }: FeatureFlags, + { + ChainflipSwap, + PortalsSwap, + LifiSwap, + ThorSwap, + ZrxSwap, + ArbitrumBridge, + Cowswap, + JupiterSwap, + }: FeatureFlags, isCrossAccountTrade: boolean, + isSolBuyAssetId: boolean, ): Record => { return { [SwapperName.LIFI]: @@ -38,9 +49,14 @@ export const getEnabledSwappers = ( ArbitrumBridge && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.ArbitrumBridge)), [SwapperName.Portals]: - Portals && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Portals)), + PortalsSwap && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Portals)), [SwapperName.Chainflip]: - Chainflip && (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Chainflip)), + ChainflipSwap && + (!isCrossAccountTrade || isCrossAccountTradeSupported(SwapperName.Chainflip)), + [SwapperName.Jupiter]: + JupiterSwap && + (!isCrossAccountTrade || + (isCrossAccountTradeSupported(SwapperName.Jupiter) && !isSolBuyAssetId)), [SwapperName.Test]: false, } } diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 1d31c153056..acd4ba5e542 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -37,7 +37,7 @@ export type FeatureFlags = { ReadOnlyAssets: boolean Jaypegz: boolean ArbitrumBridge: boolean - Portals: boolean + PortalsSwap: boolean CovalentJaypegs: boolean Chatwoot: boolean AdvancedSlippage: boolean @@ -68,10 +68,11 @@ export type FeatureFlags = { FoxPageFoxFarmingSection: boolean FoxPageGovernance: boolean LimitOrders: boolean - Chainflip: boolean + ChainflipSwap: boolean SolanaSwapper: boolean ChainflipDca: boolean ThorFreeFees: boolean + JupiterSwap: boolean } export type Flag = keyof FeatureFlags @@ -133,7 +134,7 @@ const initialState: Preferences = { DynamicLpAssets: getConfig().REACT_APP_FEATURE_DYNAMIC_LP_ASSETS, ReadOnlyAssets: getConfig().REACT_APP_FEATURE_READ_ONLY_ASSETS, ArbitrumBridge: getConfig().REACT_APP_FEATURE_ARBITRUM_BRIDGE, - Portals: getConfig().REACT_APP_FEATURE_PORTALS_SWAPPER, + PortalsSwap: getConfig().REACT_APP_FEATURE_PORTALS_SWAPPER, Chatwoot: getConfig().REACT_APP_FEATURE_CHATWOOT, AdvancedSlippage: getConfig().REACT_APP_FEATURE_ADVANCED_SLIPPAGE, WalletConnectV2: getConfig().REACT_APP_FEATURE_WALLET_CONNECT_V2, @@ -163,10 +164,11 @@ const initialState: Preferences = { FoxPageFoxFarmingSection: getConfig().REACT_APP_FEATURE_FOX_PAGE_FOX_FARMING_SECTION, FoxPageGovernance: getConfig().REACT_APP_FEATURE_FOX_PAGE_GOVERNANCE, LimitOrders: getConfig().REACT_APP_FEATURE_LIMIT_ORDERS, - Chainflip: getConfig().REACT_APP_FEATURE_CHAINFLIP, + ChainflipSwap: getConfig().REACT_APP_FEATURE_CHAINFLIP_SWAP, + ChainflipDca: getConfig().REACT_APP_FEATURE_CHAINFLIP_SWAP_DCA, SolanaSwapper: getConfig().REACT_APP_FEATURE_SWAPPER_SOLANA, - ChainflipDca: getConfig().REACT_APP_FEATURE_CHAINFLIP_DCA, ThorFreeFees: getConfig().REACT_APP_FEATURE_THOR_FREE_FEES, + JupiterSwap: getConfig().REACT_APP_FEATURE_JUPITER_SWAP, }, selectedLocale: simpleLocale(), balanceThreshold: '0', diff --git a/src/state/slices/tradeQuoteSlice/selectors.ts b/src/state/slices/tradeQuoteSlice/selectors.ts index 2c2b7c0accc..d01d2040f9f 100644 --- a/src/state/slices/tradeQuoteSlice/selectors.ts +++ b/src/state/slices/tradeQuoteSlice/selectors.ts @@ -69,8 +69,8 @@ const selectTradeQuotes = createDeepEqualOutputSelector( const selectEnabledSwappersIgnoringCrossAccountTrade = createSelector( selectFeatureFlags, featureFlags => { - // cross account trade logic is irrelevant here, so we can set the flag to false here - const enabledSwappers = getEnabledSwappers(featureFlags, false) + // cross account trade logic is irrelevant here, so we can set the flags to false here + const enabledSwappers = getEnabledSwappers(featureFlags, false, false) return Object.values(SwapperName).filter( swapperName => enabledSwappers[swapperName], ) as SwapperName[] diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index 6bb96b4a6e0..a1ff44cc046 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -121,7 +121,7 @@ export const mockStore: ReduxState = { ArbitrumBridgeClaims: false, UsdtApprovalReset: false, RunePool: false, - Portals: false, + PortalsSwap: false, Markets: false, PhantomWallet: false, FoxPage: false, @@ -130,10 +130,11 @@ export const mockStore: ReduxState = { FoxPageFoxFarmingSection: false, FoxPageGovernance: false, LimitOrders: false, - Chainflip: false, + ChainflipSwap: false, SolanaSwapper: false, ChainflipDca: false, ThorFreeFees: false, + JupiterSwap: false, }, selectedLocale: 'en', balanceThreshold: '0', diff --git a/yarn.lock b/yarn.lock index 6a88703d6e4..368a7914784 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7930,6 +7930,13 @@ __metadata: languageName: node linkType: hard +"@jup-ag/api@npm:^6.0.30": + version: 6.0.30 + resolution: "@jup-ag/api@npm:6.0.30" + checksum: a2a3fb30b1837ef2b215d54df72a7e1567be0c30a9177fe4042ac5078e1fa2106b1005098fb04d7e8b9543fc8be136d7d6496945901b724481e9982b68dfb79c + languageName: node + linkType: hard + "@keepkey/device-protocol@npm:^7.12.2": version: 7.12.2 resolution: "@keepkey/device-protocol@npm:7.12.2" @@ -11741,6 +11748,7 @@ __metadata: "@formatjs/intl-numberformat": ^8.10.3 "@formatjs/intl-pluralrules": ^5.2.14 "@json-rpc-tools/utils": ^1.7.6 + "@jup-ag/api": ^6.0.30 "@keepkey/hdwallet-keepkey-rest": 1.40.42 "@keepkey/keepkey-sdk": 0.2.57 "@keplr-wallet/types": ^0.12.21