|
| 1 | +import { PublicKey, type Connection } from '@solana/web3.js'; |
| 2 | +import { BagsSDK } from '@bagsfm/bags-sdk'; |
| 3 | +import pino from 'pino'; |
| 4 | +import type { |
| 5 | + BagsAdapter, |
| 6 | + BagsApiConfig, |
| 7 | + BagsRequestOptions, |
| 8 | + BagsRateLimitInfo, |
| 9 | + ClaimablePosition, |
| 10 | + TradeQuote, |
| 11 | + SwapTransaction, |
| 12 | + ClaimTransaction, |
| 13 | +} from '../types/index.js'; |
| 14 | + |
| 15 | +const logger = pino({ name: 'BagsSdkAdapter' }); |
| 16 | + |
| 17 | +/** |
| 18 | + * BagsAdapter implementation backed by the official @bagsfm/bags-sdk. |
| 19 | + * |
| 20 | + * Translates between SDK types (PublicKey, VersionedTransaction) and our |
| 21 | + * domain types (string addresses, base64/base58 encoded transactions). |
| 22 | + * |
| 23 | + * Advantages over raw axios BagsClient: |
| 24 | + * - Uses SDK-maintained HTTP client with built-in error handling |
| 25 | + * - Automatic SDK updates when Bags API changes |
| 26 | + * - Access to Jito bundle support via sdk.solana.sendBundle() |
| 27 | + * - Consistent type system with Bags ecosystem |
| 28 | + */ |
| 29 | +export class BagsSdkAdapter implements BagsAdapter { |
| 30 | + private readonly sdk: BagsSDK; |
| 31 | + private rateLimitInfo: BagsRateLimitInfo = { remaining: 100, resetAt: 0 }; |
| 32 | + |
| 33 | + constructor( |
| 34 | + private readonly config: BagsApiConfig, |
| 35 | + connection: Connection, |
| 36 | + ) { |
| 37 | + this.sdk = new BagsSDK(config.apiKey, connection); |
| 38 | + logger.info('BagsSdkAdapter initialized with official SDK'); |
| 39 | + } |
| 40 | + |
| 41 | + /** Expose the underlying SDK for direct access (e.g., Jito bundles). */ |
| 42 | + getSdk(): BagsSDK { |
| 43 | + return this.sdk; |
| 44 | + } |
| 45 | + |
| 46 | + async getClaimablePositions( |
| 47 | + wallet: string, |
| 48 | + _options?: BagsRequestOptions, |
| 49 | + ): Promise<ClaimablePosition[]> { |
| 50 | + logger.debug({ wallet }, 'Fetching claimable positions via SDK'); |
| 51 | + |
| 52 | + const pubkey = new PublicKey(wallet); |
| 53 | + const sdkPositions = await this.sdk.fee.getAllClaimablePositions(pubkey); |
| 54 | + |
| 55 | + // Map SDK positions to our domain type |
| 56 | + const positions: ClaimablePosition[] = sdkPositions.map((pos: Record<string, unknown>) => |
| 57 | + mapSdkPosition(pos), |
| 58 | + ); |
| 59 | + |
| 60 | + logger.info( |
| 61 | + { wallet, count: positions.length }, |
| 62 | + 'Retrieved claimable positions via SDK', |
| 63 | + ); |
| 64 | + return positions; |
| 65 | + } |
| 66 | + |
| 67 | + async getClaimTransactions( |
| 68 | + feeClaimer: string, |
| 69 | + position: ClaimablePosition, |
| 70 | + _options?: BagsRequestOptions, |
| 71 | + ): Promise<ClaimTransaction[]> { |
| 72 | + logger.debug( |
| 73 | + { feeClaimer, virtualPool: position.virtualPoolAddress }, |
| 74 | + 'Fetching claim transactions via SDK', |
| 75 | + ); |
| 76 | + |
| 77 | + const wallet = new PublicKey(feeClaimer); |
| 78 | + const tokenMint = new PublicKey(position.baseMint); |
| 79 | + |
| 80 | + const sdkTransactions = await this.sdk.fee.getClaimTransactions( |
| 81 | + wallet, |
| 82 | + tokenMint, |
| 83 | + ); |
| 84 | + |
| 85 | + // SDK returns Transaction objects; serialize back to our ClaimTransaction format |
| 86 | + const claimTxs: ClaimTransaction[] = sdkTransactions.map((tx) => { |
| 87 | + const serialized = tx.serialize(); |
| 88 | + // Use base58 encoding for claim transactions (Bags.fm convention) |
| 89 | + const encoded = Buffer.from(serialized).toString('base64'); |
| 90 | + // We need to extract blockhash from the transaction message |
| 91 | + const message = tx.compileMessage(); |
| 92 | + return { |
| 93 | + tx: encoded, |
| 94 | + blockhash: { |
| 95 | + blockhash: message.recentBlockhash, |
| 96 | + lastValidBlockHeight: 0, // SDK doesn't expose this directly |
| 97 | + }, |
| 98 | + }; |
| 99 | + }); |
| 100 | + |
| 101 | + return claimTxs; |
| 102 | + } |
| 103 | + |
| 104 | + async getTradeQuote( |
| 105 | + params: { |
| 106 | + inputMint: string; |
| 107 | + outputMint: string; |
| 108 | + amount: number; |
| 109 | + slippageBps?: number; |
| 110 | + }, |
| 111 | + _options?: BagsRequestOptions, |
| 112 | + ): Promise<TradeQuote> { |
| 113 | + logger.debug( |
| 114 | + { |
| 115 | + inputMint: params.inputMint, |
| 116 | + outputMint: params.outputMint, |
| 117 | + amount: params.amount, |
| 118 | + }, |
| 119 | + 'Fetching trade quote via SDK', |
| 120 | + ); |
| 121 | + |
| 122 | + const sdkQuote = await this.sdk.trade.getQuote({ |
| 123 | + inputMint: new PublicKey(params.inputMint), |
| 124 | + outputMint: new PublicKey(params.outputMint), |
| 125 | + amount: params.amount, |
| 126 | + slippageBps: params.slippageBps, |
| 127 | + }); |
| 128 | + |
| 129 | + // Map SDK response to our TradeQuote type |
| 130 | + const quote: TradeQuote = { |
| 131 | + requestId: sdkQuote.requestId, |
| 132 | + contextSlot: sdkQuote.contextSlot, |
| 133 | + inAmount: sdkQuote.inAmount, |
| 134 | + inputMint: sdkQuote.inputMint, |
| 135 | + outAmount: sdkQuote.outAmount, |
| 136 | + outputMint: sdkQuote.outputMint, |
| 137 | + minOutAmount: sdkQuote.minOutAmount, |
| 138 | + otherAmountThreshold: sdkQuote.minOutAmount, // SDK maps this |
| 139 | + priceImpactPct: sdkQuote.priceImpactPct, |
| 140 | + slippageBps: sdkQuote.slippageBps, |
| 141 | + routePlan: sdkQuote.routePlan?.map((leg: Record<string, unknown>) => ({ |
| 142 | + venue: (leg.venue as string) ?? '', |
| 143 | + inAmount: (leg.inAmount as string) ?? '', |
| 144 | + outAmount: (leg.outAmount as string) ?? '', |
| 145 | + inputMint: (leg.inputMint as string) ?? '', |
| 146 | + outputMint: (leg.outputMint as string) ?? '', |
| 147 | + inputMintDecimals: (leg.inputMintDecimals as number) ?? 0, |
| 148 | + outputMintDecimals: (leg.outputMintDecimals as number) ?? 0, |
| 149 | + marketKey: (leg.marketKey as string) ?? '', |
| 150 | + data: (leg.data as string) ?? '', |
| 151 | + })) ?? [], |
| 152 | + platformFee: { |
| 153 | + amount: (sdkQuote.platformFee as Record<string, unknown>)?.amount as string ?? '0', |
| 154 | + feeBps: (sdkQuote.platformFee as Record<string, unknown>)?.feeBps as number ?? 0, |
| 155 | + feeAccount: (sdkQuote.platformFee as Record<string, unknown>)?.feeAccount as string ?? '', |
| 156 | + segmenterFeeAmount: (sdkQuote.platformFee as Record<string, unknown>)?.segmenterFeeAmount as string ?? '0', |
| 157 | + segmenterFeePct: (sdkQuote.platformFee as Record<string, unknown>)?.segmenterFeePct as number ?? 0, |
| 158 | + }, |
| 159 | + outTransferFee: sdkQuote.outTransferFee ?? '0', |
| 160 | + simulatedComputeUnits: sdkQuote.simulatedComputeUnits ?? 0, |
| 161 | + }; |
| 162 | + |
| 163 | + logger.info( |
| 164 | + { |
| 165 | + inAmount: quote.inAmount, |
| 166 | + outAmount: quote.outAmount, |
| 167 | + priceImpactPct: quote.priceImpactPct, |
| 168 | + }, |
| 169 | + 'Received trade quote via SDK', |
| 170 | + ); |
| 171 | + return quote; |
| 172 | + } |
| 173 | + |
| 174 | + async createSwapTransaction( |
| 175 | + quoteResponse: TradeQuote, |
| 176 | + userPublicKey: string, |
| 177 | + _options?: BagsRequestOptions, |
| 178 | + ): Promise<SwapTransaction> { |
| 179 | + logger.debug( |
| 180 | + { requestId: quoteResponse.requestId, userPublicKey }, |
| 181 | + 'Creating swap transaction via SDK', |
| 182 | + ); |
| 183 | + |
| 184 | + const sdkResult = await this.sdk.trade.createSwapTransaction({ |
| 185 | + quoteResponse: quoteResponse as unknown as Parameters<typeof this.sdk.trade.createSwapTransaction>[0]['quoteResponse'], |
| 186 | + userPublicKey: new PublicKey(userPublicKey), |
| 187 | + }); |
| 188 | + |
| 189 | + // SDK returns VersionedTransaction object; serialize to base64 for our interface |
| 190 | + const serialized = sdkResult.transaction.serialize(); |
| 191 | + const swapTx: SwapTransaction = { |
| 192 | + swapTransaction: Buffer.from(serialized).toString('base64'), |
| 193 | + computeUnitLimit: sdkResult.computeUnitLimit, |
| 194 | + lastValidBlockHeight: sdkResult.lastValidBlockHeight, |
| 195 | + prioritizationFeeLamports: sdkResult.prioritizationFeeLamports, |
| 196 | + }; |
| 197 | + |
| 198 | + logger.info( |
| 199 | + { |
| 200 | + requestId: quoteResponse.requestId, |
| 201 | + computeUnits: swapTx.computeUnitLimit, |
| 202 | + }, |
| 203 | + 'Swap transaction created via SDK', |
| 204 | + ); |
| 205 | + return swapTx; |
| 206 | + } |
| 207 | + |
| 208 | + async prepareSwap( |
| 209 | + params: { |
| 210 | + inputMint: string; |
| 211 | + outputMint: string; |
| 212 | + amount: number; |
| 213 | + userPublicKey: string; |
| 214 | + slippageBps?: number; |
| 215 | + maxPriceImpactBps?: number; |
| 216 | + }, |
| 217 | + options?: BagsRequestOptions, |
| 218 | + ): Promise<{ quote: TradeQuote; swapTx: SwapTransaction }> { |
| 219 | + const quote = await this.getTradeQuote( |
| 220 | + { |
| 221 | + inputMint: params.inputMint, |
| 222 | + outputMint: params.outputMint, |
| 223 | + amount: params.amount, |
| 224 | + slippageBps: params.slippageBps, |
| 225 | + }, |
| 226 | + options, |
| 227 | + ); |
| 228 | + |
| 229 | + if ( |
| 230 | + params.maxPriceImpactBps !== undefined && |
| 231 | + parseFloat(quote.priceImpactPct) * 100 > params.maxPriceImpactBps |
| 232 | + ) { |
| 233 | + throw new Error( |
| 234 | + `Price impact ${(parseFloat(quote.priceImpactPct) * 100).toFixed(2)} bps exceeds max ${params.maxPriceImpactBps} bps`, |
| 235 | + ); |
| 236 | + } |
| 237 | + |
| 238 | + const swapTx = await this.createSwapTransaction( |
| 239 | + quote, |
| 240 | + params.userPublicKey, |
| 241 | + options, |
| 242 | + ); |
| 243 | + return { quote, swapTx }; |
| 244 | + } |
| 245 | + |
| 246 | + async getTotalClaimableSol( |
| 247 | + wallet: string, |
| 248 | + options?: BagsRequestOptions, |
| 249 | + ): Promise<{ totalLamports: bigint; positions: ClaimablePosition[] }> { |
| 250 | + const positions = await this.getClaimablePositions(wallet, options); |
| 251 | + const totalLamports = positions.reduce( |
| 252 | + (sum, pos) => sum + BigInt(pos.totalClaimableLamportsUserShare), |
| 253 | + 0n, |
| 254 | + ); |
| 255 | + return { totalLamports, positions }; |
| 256 | + } |
| 257 | + |
| 258 | + getRateLimitStatus(): BagsRateLimitInfo { |
| 259 | + return { ...this.rateLimitInfo }; |
| 260 | + } |
| 261 | +} |
| 262 | + |
| 263 | +/** |
| 264 | + * Map an SDK BagsClaimablePosition to our ClaimablePosition domain type. |
| 265 | + * The SDK returns union types; we normalize to a flat structure. |
| 266 | + */ |
| 267 | +function mapSdkPosition(pos: Record<string, unknown>): ClaimablePosition { |
| 268 | + return { |
| 269 | + isCustomFeeVault: (pos.isCustomFeeVault as boolean) ?? false, |
| 270 | + baseMint: String(pos.baseMint ?? ''), |
| 271 | + isMigrated: (pos.isMigrated as boolean) ?? false, |
| 272 | + totalClaimableLamportsUserShare: |
| 273 | + (pos.totalClaimableLamportsUserShare as number) ?? 0, |
| 274 | + programId: String(pos.programId ?? ''), |
| 275 | + quoteMint: String(pos.quoteMint ?? ''), |
| 276 | + virtualPool: String(pos.virtualPool ?? ''), |
| 277 | + virtualPoolAddress: String(pos.virtualPoolAddress ?? ''), |
| 278 | + virtualPoolClaimableAmount: |
| 279 | + (pos.virtualPoolClaimableAmount as number) ?? 0, |
| 280 | + virtualPoolClaimableLamportsUserShare: |
| 281 | + (pos.virtualPoolClaimableLamportsUserShare as number) ?? 0, |
| 282 | + dammPoolClaimableAmount: (pos.dammPoolClaimableAmount as number) ?? 0, |
| 283 | + dammPoolClaimableLamportsUserShare: |
| 284 | + (pos.dammPoolClaimableLamportsUserShare as number) ?? 0, |
| 285 | + dammPoolAddress: String(pos.dammPoolAddress ?? ''), |
| 286 | + dammPositionInfo: pos.dammPositionInfo as ClaimablePosition['dammPositionInfo'], |
| 287 | + claimableDisplayAmount: (pos.claimableDisplayAmount as number) ?? 0, |
| 288 | + user: String(pos.user ?? ''), |
| 289 | + claimerIndex: (pos.claimerIndex as number) ?? 0, |
| 290 | + userBps: (pos.userBps as number) ?? 0, |
| 291 | + customFeeVault: String(pos.customFeeVault ?? ''), |
| 292 | + customFeeVaultClaimerA: String(pos.customFeeVaultClaimerA ?? ''), |
| 293 | + customFeeVaultClaimerB: String(pos.customFeeVaultClaimerB ?? ''), |
| 294 | + customFeeVaultClaimerSide: |
| 295 | + (pos.customFeeVaultClaimerSide as 'A' | 'B') ?? 'A', |
| 296 | + }; |
| 297 | +} |
0 commit comments