From 2e166be64a4b30bc082e5c6f9ed6db995a7573c6 Mon Sep 17 00:00:00 2001 From: Jeremy Wong Date: Mon, 20 Oct 2025 19:22:55 -0400 Subject: [PATCH] sdk: add titan client --- package.json | 7 +- sdk/src/driftClient.ts | 195 +++++++++++++++-- sdk/src/titan/titanClient.ts | 414 +++++++++++++++++++++++++++++++++++ yarn.lock | 5 + 4 files changed, 596 insertions(+), 25 deletions(-) create mode 100644 sdk/src/titan/titanClient.ts diff --git a/package.json b/package.json index 854fea3020..4e4b68a0b4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@project-serum/common": "0.0.1-beta.3", "@project-serum/serum": "0.13.65", "@pythnetwork/client": "2.21.0", + "@pythnetwork/price-service-client": "1.9.0", "@solana/spl-token": "0.4.13", "@solana/web3.js": "1.73.2", "@types/bn.js": "5.1.6", @@ -24,11 +25,11 @@ "husky": "7.0.4", "prettier": "3.0.1", "typedoc": "0.23.23", - "typescript": "5.4.5", - "@pythnetwork/price-service-client": "1.9.0" + "typescript": "5.4.5" }, "dependencies": { "@ellipsis-labs/phoenix-sdk": "1.4.2", + "@msgpack/msgpack": "^3.1.2", "@pythnetwork/pyth-solana-receiver": "0.8.0", "@switchboard-xyz/common": "3.0.14", "@switchboard-xyz/on-demand": "2.4.1", @@ -95,4 +96,4 @@ "supports-hyperlinks": "<4.1.1", "has-ansi": "<6.0.1" } -} \ No newline at end of file +} diff --git a/sdk/src/driftClient.ts b/sdk/src/driftClient.ts index 4c71a49abf..82b0a4768e 100644 --- a/sdk/src/driftClient.ts +++ b/sdk/src/driftClient.ts @@ -186,6 +186,7 @@ import { createMinimalEd25519VerifyIx } from './util/ed25519Utils'; import { createNativeInstructionDiscriminatorBuffer, isVersionedTransaction, + MAX_TX_BYTE_SIZE, } from './tx/utils'; import pythSolanaReceiverIdl from './idl/pyth_solana_receiver.json'; import { asV0Tx, PullFeed, AnchorUtils } from '@switchboard-xyz/on-demand'; @@ -204,6 +205,12 @@ import { isBuilderOrderReferral, isBuilderOrderCompleted, } from './math/builder'; +import { TitanClient, SwapMode as TitanSwapMode } from './titan/titanClient'; + +/** + * Union type for swap clients (Titan and Jupiter) + */ +export type SwapClient = TitanClient | JupiterClient; type RemainingAccountParams = { userAccounts: UserAccount[]; @@ -5729,23 +5736,23 @@ export class DriftClient { } /** - * Swap tokens in drift account using jupiter - * @param jupiterClient jupiter client to find routes and jupiter instructions + * Swap tokens in drift account using titan or jupiter + * @param swapClient swap client to find routes and instructions (Titan or Jupiter) * @param outMarketIndex the market index of the token you're buying * @param inMarketIndex the market index of the token you're selling - * @param outAssociatedTokenAccount the token account to receive the token being sold on jupiter + * @param outAssociatedTokenAccount the token account to receive the token being sold on titan or jupiter * @param inAssociatedTokenAccount the token account to * @param amount the amount of TokenIn, regardless of swapMode - * @param slippageBps the max slippage passed to jupiter api - * @param swapMode jupiter swapMode (ExactIn or ExactOut), default is ExactIn - * @param route the jupiter route to use for the swap + * @param slippageBps the max slippage passed to titan or jupiter api + * @param swapMode titan or jupiter swapMode (ExactIn or ExactOut), default is ExactIn + * @param route the titan or jupiter route to use for the swap * @param reduceOnly specify if In or Out token on the drift account must reduceOnly, checked at end of swap * @param v6 pass in the quote response from Jupiter quote's API (deprecated, use quote instead) * @param quote pass in the quote response from Jupiter quote's API * @param txParams */ public async swap({ - jupiterClient, + swapClient, outMarketIndex, inMarketIndex, outAssociatedTokenAccount, @@ -5759,7 +5766,7 @@ export class DriftClient { quote, onlyDirectRoutes = false, }: { - jupiterClient: JupiterClient; + swapClient: SwapClient; outMarketIndex: number; inMarketIndex: number; outAssociatedTokenAccount?: PublicKey; @@ -5775,21 +5782,45 @@ export class DriftClient { }; quote?: QuoteResponse; }): Promise { - const quoteToUse = quote ?? v6?.quote; + let res: { + ixs: TransactionInstruction[]; + lookupTables: AddressLookupTableAccount[]; + }; + + if (swapClient instanceof TitanClient) { + res = await this.getTitanSwapIx({ + titanClient: swapClient, + outMarketIndex, + inMarketIndex, + outAssociatedTokenAccount, + inAssociatedTokenAccount, + amount, + slippageBps, + swapMode, + onlyDirectRoutes, + reduceOnly, + }); + } else if (swapClient instanceof JupiterClient) { + const quoteToUse = quote ?? v6?.quote; + res = await this.getJupiterSwapIxV6({ + jupiterClient: swapClient, + outMarketIndex, + inMarketIndex, + outAssociatedTokenAccount, + inAssociatedTokenAccount, + amount, + slippageBps, + swapMode, + quote: quoteToUse, + reduceOnly, + onlyDirectRoutes, + }); + } else { + throw new Error( + 'Invalid swap client type. Must be TitanClient or JupiterClient.' + ); + } - const res = await this.getJupiterSwapIxV6({ - jupiterClient, - outMarketIndex, - inMarketIndex, - outAssociatedTokenAccount, - inAssociatedTokenAccount, - amount, - slippageBps, - swapMode, - quote: quoteToUse, - reduceOnly, - onlyDirectRoutes, - }); const ixs = res.ixs; const lookupTables = res.lookupTables; @@ -5807,6 +5838,126 @@ export class DriftClient { return txSig; } + public async getTitanSwapIx({ + titanClient, + outMarketIndex, + inMarketIndex, + outAssociatedTokenAccount, + inAssociatedTokenAccount, + amount, + slippageBps, + swapMode, + onlyDirectRoutes, + reduceOnly, + userAccountPublicKey, + }: { + titanClient: TitanClient; + outMarketIndex: number; + inMarketIndex: number; + outAssociatedTokenAccount?: PublicKey; + inAssociatedTokenAccount?: PublicKey; + amount: BN; + slippageBps?: number; + swapMode?: string; + onlyDirectRoutes?: boolean; + reduceOnly?: SwapReduceOnly; + userAccountPublicKey?: PublicKey; + }): Promise<{ + ixs: TransactionInstruction[]; + lookupTables: AddressLookupTableAccount[]; + }> { + const outMarket = this.getSpotMarketAccount(outMarketIndex); + const inMarket = this.getSpotMarketAccount(inMarketIndex); + + const isExactOut = swapMode === 'ExactOut'; + const exactOutBufferedAmountIn = amount.muln(1001).divn(1000); // Add 10bp buffer + + const preInstructions = []; + if (!outAssociatedTokenAccount) { + const tokenProgram = this.getTokenProgramForSpotMarket(outMarket); + outAssociatedTokenAccount = await this.getAssociatedTokenAccount( + outMarket.marketIndex, + false, + tokenProgram + ); + + const accountInfo = await this.connection.getAccountInfo( + outAssociatedTokenAccount + ); + if (!accountInfo) { + preInstructions.push( + this.createAssociatedTokenAccountIdempotentInstruction( + outAssociatedTokenAccount, + this.provider.wallet.publicKey, + this.provider.wallet.publicKey, + outMarket.mint, + tokenProgram + ) + ); + } + } + + if (!inAssociatedTokenAccount) { + const tokenProgram = this.getTokenProgramForSpotMarket(inMarket); + inAssociatedTokenAccount = await this.getAssociatedTokenAccount( + inMarket.marketIndex, + false, + tokenProgram + ); + + const accountInfo = await this.connection.getAccountInfo( + inAssociatedTokenAccount + ); + if (!accountInfo) { + preInstructions.push( + this.createAssociatedTokenAccountIdempotentInstruction( + inAssociatedTokenAccount, + this.provider.wallet.publicKey, + this.provider.wallet.publicKey, + inMarket.mint, + tokenProgram + ) + ); + } + } + + const { beginSwapIx, endSwapIx } = await this.getSwapIx({ + outMarketIndex, + inMarketIndex, + amountIn: isExactOut ? exactOutBufferedAmountIn : amount, + inTokenAccount: inAssociatedTokenAccount, + outTokenAccount: outAssociatedTokenAccount, + reduceOnly, + userAccountPublicKey, + }); + + const { transactionMessage, lookupTables } = await titanClient.getSwap({ + inputMint: inMarket.mint, + outputMint: outMarket.mint, + amount, + userPublicKey: this.provider.wallet.publicKey, + slippageBps, + swapMode: isExactOut ? TitanSwapMode.ExactOut : TitanSwapMode.ExactIn, + onlyDirectRoutes, + sizeConstraint: MAX_TX_BYTE_SIZE - 375, // buffer for drift instructions + }); + + const titanInstructions = titanClient.getTitanInstructions({ + transactionMessage, + inputMint: inMarket.mint, + outputMint: outMarket.mint, + }); + + const ixs = [ + ...preInstructions, + beginSwapIx, + ...titanInstructions, + endSwapIx, + ]; + + return { ixs, lookupTables }; + } + public async getJupiterSwapIxV6({ jupiterClient, outMarketIndex, diff --git a/sdk/src/titan/titanClient.ts b/sdk/src/titan/titanClient.ts new file mode 100644 index 0000000000..e85251957c --- /dev/null +++ b/sdk/src/titan/titanClient.ts @@ -0,0 +1,414 @@ +import { + Connection, + PublicKey, + TransactionMessage, + AddressLookupTableAccount, + TransactionInstruction, +} from '@solana/web3.js'; +import { BN } from '@coral-xyz/anchor'; +import { decode } from '@msgpack/msgpack'; + +export enum SwapMode { + ExactIn = 'ExactIn', + ExactOut = 'ExactOut', +} + +interface RoutePlanStep { + ammKey: Uint8Array; + label: string; + inputMint: Uint8Array; + outputMint: Uint8Array; + inAmount: number; + outAmount: number; + allocPpb: number; + feeMint?: Uint8Array; + feeAmount?: number; + contextSlot?: number; +} + +interface PlatformFee { + amount: number; + fee_bps: number; +} + +type Pubkey = Uint8Array; + +interface AccountMeta { + p: Pubkey; + s: boolean; + w: boolean; +} + +interface Instruction { + p: Pubkey; + a: AccountMeta[]; + d: Uint8Array; +} + +interface SwapRoute { + inAmount: number; + outAmount: number; + slippageBps: number; + platformFee?: PlatformFee; + steps: RoutePlanStep[]; + instructions: Instruction[]; + addressLookupTables: Pubkey[]; + contextSlot?: number; + timeTaken?: number; + expiresAtMs?: number; + expiresAfterSlot?: number; + computeUnits?: number; + computeUnitsSafe?: number; + transaction?: Uint8Array; + referenceId?: string; +} + +interface SwapQuotes { + id: string; + inputMint: Uint8Array; + outputMint: Uint8Array; + swapMode: SwapMode; + amount: number; + quotes: { [key: string]: SwapRoute }; +} + +export interface QuoteResponse { + inputMint: string; + inAmount: string; + outputMint: string; + outAmount: string; + swapMode: SwapMode; + slippageBps: number; + platformFee?: { amount?: string; feeBps?: number }; + routePlan: Array<{ swapInfo: any; percent: number }>; + contextSlot?: number; + timeTaken?: number; + error?: string; + errorCode?: string; +} + +const TITAN_API_URL = 'https://api.titan.exchange'; + +export class TitanClient { + authToken: string; + url: string; + connection: Connection; + + constructor({ + connection, + authToken, + url, + }: { + connection: Connection; + authToken: string; + url?: string; + }) { + this.connection = connection; + this.authToken = authToken; + this.url = url ?? TITAN_API_URL; + } + + /** + * Get routes for a swap + */ + public async getQuote({ + inputMint, + outputMint, + amount, + userPublicKey, + maxAccounts = 50, // 50 is an estimated amount with buffer + slippageBps, + swapMode, + onlyDirectRoutes, + excludeDexes, + sizeConstraint, + accountsLimitWritable, + }: { + inputMint: PublicKey; + outputMint: PublicKey; + amount: BN; + userPublicKey: PublicKey; + maxAccounts?: number; + slippageBps?: number; + swapMode?: string; + onlyDirectRoutes?: boolean; + excludeDexes?: string[]; + sizeConstraint?: number; + accountsLimitWritable?: number; + }): Promise { + const params = new URLSearchParams({ + inputMint: inputMint.toString(), + outputMint: outputMint.toString(), + amount: amount.toString(), + userPublicKey: userPublicKey.toString(), + ...(slippageBps && { slippageBps: slippageBps.toString() }), + ...(swapMode && { + swapMode: + swapMode === 'ExactOut' ? SwapMode.ExactOut : SwapMode.ExactIn, + }), + ...(onlyDirectRoutes && { + onlyDirectRoutes: onlyDirectRoutes.toString(), + }), + ...(maxAccounts && { accountsLimitTotal: maxAccounts.toString() }), + ...(excludeDexes && { excludeDexes: excludeDexes.join(',') }), + ...(sizeConstraint && { sizeConstraint: sizeConstraint.toString() }), + ...(accountsLimitWritable && { + accountsLimitWritable: accountsLimitWritable.toString(), + }), + }); + + const response = await fetch( + `${this.url}/api/v1/quote/swap?${params.toString()}`, + { + headers: { + Accept: 'application/vnd.msgpack', + 'Accept-Encoding': 'gzip, deflate, br', + Authorization: `Bearer ${this.authToken}`, + }, + } + ); + + if (!response.ok) { + throw new Error( + `Titan API error: ${response.status} ${response.statusText}` + ); + } + + const buffer = await response.arrayBuffer(); + const data = decode(buffer) as SwapQuotes; + + const route = + data.quotes[ + Object.keys(data.quotes).find((key) => key.toLowerCase() === 'titan') || + '' + ]; + + if (!route) { + throw new Error('No routes available'); + } + + return { + inputMint: inputMint.toString(), + inAmount: amount.toString(), + outputMint: outputMint.toString(), + outAmount: route.outAmount.toString(), + swapMode: data.swapMode, + slippageBps: route.slippageBps, + platformFee: route.platformFee + ? { + amount: route.platformFee.amount.toString(), + feeBps: route.platformFee.fee_bps, + } + : undefined, + routePlan: + route.steps?.map((step: any) => ({ + swapInfo: { + ammKey: new PublicKey(step.ammKey).toString(), + label: step.label, + inputMint: new PublicKey(step.inputMint).toString(), + outputMint: new PublicKey(step.outputMint).toString(), + inAmount: step.inAmount.toString(), + outAmount: step.outAmount.toString(), + feeAmount: step.feeAmount?.toString() || '0', + feeMint: step.feeMint ? new PublicKey(step.feeMint).toString() : '', + }, + percent: 100, + })) || [], + contextSlot: route.contextSlot, + timeTaken: route.timeTaken, + }; + } + + /** + * Get a swap transaction for quote + */ + public async getSwap({ + inputMint, + outputMint, + amount, + userPublicKey, + maxAccounts = 50, // 50 is an estimated amount with buffer + slippageBps, + swapMode, + onlyDirectRoutes, + excludeDexes, + sizeConstraint, + accountsLimitWritable, + }: { + inputMint: PublicKey; + outputMint: PublicKey; + amount: BN; + userPublicKey: PublicKey; + maxAccounts?: number; + slippageBps?: number; + swapMode?: SwapMode; + onlyDirectRoutes?: boolean; + excludeDexes?: string[]; + sizeConstraint?: number; + accountsLimitWritable?: number; + }): Promise<{ + transactionMessage: TransactionMessage; + lookupTables: AddressLookupTableAccount[]; + }> { + const params = new URLSearchParams({ + inputMint: inputMint.toString(), + outputMint: outputMint.toString(), + amount: amount.toString(), + userPublicKey: userPublicKey.toString(), + ...(slippageBps && { slippageBps: slippageBps.toString() }), + ...(swapMode && { swapMode: swapMode }), + ...(maxAccounts && { accountsLimitTotal: maxAccounts.toString() }), + ...(excludeDexes && { excludeDexes: excludeDexes.join(',') }), + ...(onlyDirectRoutes && { + onlyDirectRoutes: onlyDirectRoutes.toString(), + }), + ...(sizeConstraint && { sizeConstraint: sizeConstraint.toString() }), + ...(accountsLimitWritable && { + accountsLimitWritable: accountsLimitWritable.toString(), + }), + }); + + const response = await fetch( + `${this.url}/api/v1/quote/swap?${params.toString()}`, + { + headers: { + Accept: 'application/vnd.msgpack', + 'Accept-Encoding': 'gzip, deflate, br', + Authorization: `Bearer ${this.authToken}`, + }, + } + ); + + if (!response.ok) { + if (response.status === 404) { + throw new Error('No routes available'); + } + throw new Error( + `Titan API error: ${response.status} ${response.statusText}` + ); + } + + const buffer = await response.arrayBuffer(); + const data = decode(buffer) as SwapQuotes; + + const route = + data.quotes[ + Object.keys(data.quotes).find((key) => key.toLowerCase() === 'titan') || + '' + ]; + + if (!route) { + throw new Error('No routes available'); + } + + if (route.instructions && route.instructions.length > 0) { + try { + const { transactionMessage, lookupTables } = + await this.getTransactionMessageAndLookupTables(route, userPublicKey); + return { transactionMessage, lookupTables }; + } catch (err) { + throw new Error( + 'Something went wrong with creating the Titan swap transaction. Please try again.' + ); + } + } + throw new Error('No instructions provided in the route'); + } + + /** + * Get the titan instructions from transaction by filtering out instructions to compute budget and associated token programs + * @param transactionMessage the transaction message + * @param inputMint the input mint + * @param outputMint the output mint + */ + public getTitanInstructions({ + transactionMessage, + inputMint, + outputMint, + }: { + transactionMessage: TransactionMessage; + inputMint: PublicKey; + outputMint: PublicKey; + }): TransactionInstruction[] { + // Filter out common system instructions that can be handled by DriftClient + const filteredInstructions = transactionMessage.instructions.filter( + (instruction) => { + const programId = instruction.programId.toString(); + + // Filter out system programs + if (programId === 'ComputeBudget111111111111111111111111111111') { + return false; + } + + if (programId === 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA') { + return false; + } + + if (programId === '11111111111111111111111111111111') { + return false; + } + + // Filter out Associated Token Account creation for input/output mints + if (programId === 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL') { + if (instruction.keys.length > 3) { + const mint = instruction.keys[3].pubkey; + if (mint.equals(inputMint) || mint.equals(outputMint)) { + return false; + } + } + } + + return true; + } + ); + return filteredInstructions; + } + + private async getTransactionMessageAndLookupTables( + route: SwapRoute, + userPublicKey: PublicKey + ): Promise<{ + transactionMessage: TransactionMessage; + lookupTables: AddressLookupTableAccount[]; + }> { + const solanaInstructions: TransactionInstruction[] = route.instructions.map( + (instruction) => ({ + programId: new PublicKey(instruction.p), + keys: instruction.a.map((meta) => ({ + pubkey: new PublicKey(meta.p), + isSigner: meta.s, + isWritable: meta.w, + })), + data: Buffer.from(instruction.d), + }) + ); + + // Get recent blockhash + const { blockhash } = await this.connection.getLatestBlockhash(); + + // Build address lookup tables if provided + const addressLookupTables: AddressLookupTableAccount[] = []; + if (route.addressLookupTables && route.addressLookupTables.length > 0) { + for (const altPubkey of route.addressLookupTables) { + try { + const altAccount = await this.connection.getAddressLookupTable( + new PublicKey(altPubkey) + ); + if (altAccount.value) { + addressLookupTables.push(altAccount.value); + } + } catch (err) { + console.warn(`Failed to fetch address lookup table:`, err); + } + } + } + + const transactionMessage = new TransactionMessage({ + payerKey: userPublicKey, + recentBlockhash: blockhash, + instructions: solanaInstructions, + }); + + return { transactionMessage, lookupTables: addressLookupTables }; + } +} diff --git a/yarn.lock b/yarn.lock index 2678bc1b59..cafe370cd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -225,6 +225,11 @@ snake-case "^3.0.4" spok "^1.4.3" +"@msgpack/msgpack@^3.1.2": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-3.1.2.tgz#fdd25cc2202297519798bbaf4689152ad9609e19" + integrity sha512-JEW4DEtBzfe8HvUYecLU9e6+XJnKDlUAIve8FvPzF3Kzs6Xo/KuZkZJsDH0wJXl/qEZbeeE7edxDNY3kMs39hQ== + "@noble/curves@^1.0.0", "@noble/curves@^1.4.2": version "1.9.2" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.2.tgz#73388356ce733922396214a933ff7c95afcef911"