diff --git a/src/services/api/helius.ts b/src/services/api/helius.ts index 94eeee0..7a8d032 100644 --- a/src/services/api/helius.ts +++ b/src/services/api/helius.ts @@ -1,16 +1,27 @@ +import type { Address } from '@solana/addresses'; +import type { Signature } from '@solana/keys'; import { createHelius } from "helius-sdk"; -import { RateLimiter } from "../../utils/rateLimiter.js"; +import { RateLimiter } from "../../utils/rateLimiter"; + +export interface HeliusTransaction { + signature: string; + slot: number; + blockTime: number; + error: string; + memo: string; + confirmationStatus: "finalized" | "confirmed" | "processed"; +} export interface HeliusService { getAccountInfo(address: string, config?: any): Promise; - getBlock(address: string): Promise; + getBlock(slot: bigint): Promise; getSignaturesForAddress(address: string, config: any): Promise; getAssetsByOwner(config: { ownerAddress: string }): Promise; getTokenAccounts(config: { owner: string }): Promise; getBalance(address: string): Promise; getTransaction(signature: string, config?: any): Promise; getTransactionsForAddress(address: string, limit: number, before?: string): Promise<{ - transactions: any[]; + transactions: HeliusTransaction[]; signatures: string[]; paginationToken?: string; hasMore: boolean; @@ -42,19 +53,19 @@ export function createHeliusService(apiKey: string, requestsPerSecond: number = return { getAccountInfo: async (address: string, config?: any) => { return withRateLimit(async () => { - return helius.getAccountInfo(address, config || { encoding: "base64" }); + return helius.getAccountInfo(address as Address, config || { encoding: "base64" }); }); }, - getBlock: async (address: string) => { + getBlock: async (slot: bigint) => { return withRateLimit(async () => { - return helius.getBlock(address); + return helius.getBlock(slot); }); }, getSignaturesForAddress: async (address: string, config: any) => { return withRateLimit(async () => { - return helius.getSignaturesForAddress(address, config); + return helius.getSignaturesForAddress(address as Address, config); }); }, @@ -70,26 +81,36 @@ export function createHeliusService(apiKey: string, requestsPerSecond: number = }); }, - getBalance: async (address: string) => { + getBalance: async (address: string): Promise => { return withRateLimit(async () => { - return helius.getBalance(address); + return helius.getBalance(address as Address); }); }, - getTransaction: async (signature: string, config?: any) => { + getTransaction: async (signature: Signature, config?: any) => { return withRateLimit(async () => { return helius.getTransaction(signature, config || { maxSupportedTransactionVersion: 0 }); }); }, - getTransactionsForAddress: async (address: string, limit: number, before?: string) => { + /** + * Fetches transactions for a given address using Helius's optimized RPC method + * This method combines getSignaturesForAddress + getTransaction in a single call, + * significantly reducing API calls and improving performance + * + * @param address - The Solana address to fetch transactions for + * @param limit - Maximum number of transactions to return (max 100 with full details) + * @param before - Optional pagination token from previous request + * @returns Object containing transactions, signatures, pagination token, and hasMore flag + */ + getTransactionsForAddress: async (address: string, limit: number, before?: string) :Promise<{ transactions: HeliusTransaction[]; signatures: string[]; paginationToken?: string; hasMore: boolean }> => { // Use new Helius getTransactionsForAddress RPC method // This combines getSignaturesForAddress + getTransaction in 1 call! // Limit: max 100 transactions with full details return withRateLimit(async () => { const params: any = { - transactionDetails: 'full', // Get full transaction data + transactionDetails: 'signatures', // Get full transaction data limit: Math.min(limit, 100), // Max 100 with full details }; @@ -122,24 +143,27 @@ export function createHeliusService(apiKey: string, requestsPerSecond: number = } // Filter out failed transactions - const validTransactions = result.data.filter((tx: any) => + const validTransactions: HeliusTransaction[] = result.data.filter((tx: any) => tx.meta?.err === null || tx.meta?.err === undefined ); - return { transactions: validTransactions, - signatures: validTransactions.map((tx: any) => tx.transaction.signatures[0]), + signatures: validTransactions.map((tx: HeliusTransaction) => Array.isArray(tx.signature) ? tx.signature[0] : tx.signature), paginationToken: result.paginationToken || undefined, hasMore: !!result.paginationToken }; }); }, + /** + * Fetches token metadata (symbol and name) for multiple tokens in batch + * Uses Helius DAS API getAssetBatch which is much more efficient than + * calling external APIs (like Birdeye) for each token individually + * + * @param mints - Array of token mint addresses to fetch metadata for + * @returns Map of mint address to metadata object containing symbol and name + */ getTokenMetadataBatch: async (mints: string[]) => { - // Use Helius DAS API getAssetBatch to fetch metadata for multiple tokens - // This is MUCH more efficient than calling Birdeye token_overview for each token - // Limit: up to 1000 tokens per request - return withRateLimit(async () => { const metadataMap = new Map(); diff --git a/src/utils/decoder/index.ts b/src/utils/decoder/index.ts index b30e1d0..1615270 100644 --- a/src/utils/decoder/index.ts +++ b/src/utils/decoder/index.ts @@ -12,10 +12,41 @@ import { poolKeysSchema } from "./pumpswap/schema.js"; import { poolLayoutSchema } from "./meteora/schema.js"; import { raydiumPoolSchema } from "./raydium/schema.js"; import { createMeteoraTrade, createRaydiumTrade } from "./tradingUtils.js"; -import { extractBalances } from "./extractBalances.js"; -// import { extractBalances } from "./extractBalances.js"; +import { BalanceAnalysis, extractBalances } from "./extractBalances"; +import { HeliusTransaction, createHeliusService } from "../../services/api/helius"; import bs58 from "bs58"; +export interface TxDecodeResult { + signature: string; + recentBlockhash: string; + blockTime: number; + fee: any; + error: string; + status: any; + accounts: any[]; + decodedInstructions: any[]; + parsed: any[]; + balances: BalanceAnalysis; + totalInstructions: number; + totalAccountsKeys: number; + totalWritableKeys: number; + totalReadonlyKeys: number; + totalInnerInstructions: any; + successfullyDecoded: number; +} + +export interface SolanaInstruction { + programIdIndex: number; + accounts: number[]; + data: string; + stackHeight: number; +} + +export interface InnerInstruction { + index: number; + instructions: SolanaInstruction[]; +} + // -------------------------------- // Serializing big ints to strings // -------------------------------- @@ -36,7 +67,7 @@ export const decodeBase64Data = (data: any) => { return Buffer.from(data, 'base64'); } -export const routerDecoderInstructionsData = (type: string, programId: string, instruction: any) => { +export const routerDecoderInstructionsData = (type: string, programId: string, instruction: SolanaInstruction) => { try { switch (programId) { // COMPUTE_BUDGET_PROGRAM_ID @@ -62,8 +93,7 @@ export const routerDecoderInstructionsData = (type: string, programId: string, i return meteoraDecoder(type, programId, instruction); // PROGRAM_JUPITER_V6 case "JUP6LkbZbjS1jKKwapdHNy74zcZ3tLUZoi5QNyVTaV4": - return jupiterDecoder(type, programId, instruction); - // return pumpswapDecoder(programId, instruction); + return jupiterDecoder(instruction); default: return null; } @@ -123,7 +153,7 @@ export const extractSwapData = async (programId: string, decoded: any, tx: any) const dataDecoded = decodeBase64Data(accountInfo.value.data[0]); const poolKeys = poolKeysSchema.decode(dataDecoded); - return{ + return { programId, signature: tx.transaction.signatures[0], timestamp: serializedBigInt(tx.blockTime), @@ -173,12 +203,13 @@ export const extractSwapData = async (programId: string, decoded: any, tx: any) return null; }; -export const decodeTxData = async (tx: any) => { +export const decodeTxData = async (tx: HeliusTransaction): Promise => { + // Guard: vérifier que la transaction a la structure minimale requise - if (!tx || !tx.transaction || !tx.transaction.message) { + if (!tx || !tx.signature || !tx.blockTime) { // Retourner une structure vide pour les transactions invalides (skip silencieusement) return { - signature: [], + signature: '', recentBlockhash: '', blockTime: 0, fee: 0, @@ -204,82 +235,90 @@ export const decodeTxData = async (tx: any) => { successfullyDecoded: 0 }; } + const heliusService = createHeliusService(process.env.HELIUS_API_KEY || '', 10); + try { + const transactionInfo = await heliusService.getTransaction(tx.signature); - // Extraire les balances avec le nouveau module - const balanceAnalysis = await extractBalances(tx); - const instructions = tx.transaction.message.instructions; - const innerInstructions = tx.meta.innerInstructions; - const accounts = [ - ...tx.transaction.message.accountKeys, - ...(tx.meta?.loadedAddresses?.writable ?? []), - ...(tx.meta?.loadedAddresses?.readonly ?? []) - ]; - const decodedInstructions: any[] = []; - const parsed: any[] = []; + // Extraire les balances avec le nouveau module + const balanceAnalysis = await extractBalances(transactionInfo); + const instructions: SolanaInstruction[] = transactionInfo.transaction.message.instructions; + const innerInstructions: InnerInstruction[] | undefined = transactionInfo.meta.innerInstructions; + + const accounts: string[] = [ + ...transactionInfo.transaction.message.accountKeys, + ...(transactionInfo.meta?.loadedAddresses?.writable ?? []), + ...(transactionInfo.meta?.loadedAddresses?.readonly ?? []) + ]; + const decodedInstructions: any[] = []; + const parsed: any[] = []; - // Process main instructions - for (const instruction of instructions) { - try { - const programId = accounts[instruction.programIdIndex]; - const decoded = routerDecoderInstructionsData('instruction', programId, instruction); + // Process main instructions + for (const instruction of instructions) { + try { + const programId = accounts[instruction.programIdIndex]; + const decoded = routerDecoderInstructionsData('instruction', programId, instruction); - if (decoded) { - decodedInstructions.push({ - programId, - type: "main", - instruction, - decoded - }); - // Si c’est un swap Jupiter, Pumpfun, Orca, etc. - const swap = await extractSwapData(programId, decoded, tx); - if (swap) parsed.push(swap); + if (decoded) { + decodedInstructions.push({ + programId, + type: "main", + instruction, + decoded + }); + // Si c’est un swap Jupiter, Pumpfun, Orca, etc. + const swap = await extractSwapData(programId, decoded, transactionInfo); + if (swap) parsed.push(swap); + } + } catch (error) { + console.log("Error processing instruction:", error instanceof Error ? error.message : String(error)); } - } catch (error) { - console.log("Error processing instruction:", error instanceof Error ? error.message : String(error)); } - } - // Process inner instructions - if (innerInstructions) { - for (const innerInst of innerInstructions) { - for (const instruction of innerInst.instructions) { - try { - const programId = accounts[instruction.programIdIndex]; - const decoded = routerDecoderInstructionsData('instruction', programId, instruction); + // Process inner instructions + if (innerInstructions) { + for (const innerInst of innerInstructions) { + for (const instruction of innerInst.instructions) { + try { + const programId = accounts[instruction.programIdIndex]; + const decoded = routerDecoderInstructionsData('instruction', programId, instruction); - if (decoded) { - decodedInstructions.push({ - programId, - type: "inner", - instruction, - decoded - }); - const swap = await extractSwapData(programId, decoded, tx); - if (swap) parsed.push(swap); + if (decoded) { + decodedInstructions.push({ + programId, + type: "inner", + instruction, + decoded + }); + const swap = await extractSwapData(programId, decoded, transactionInfo); + if (swap) parsed.push(swap); + } + } catch (error) { + console.log("Error processing inner instruction:", error instanceof Error ? error.message : String(error)); } - } catch (error) { - console.log("Error processing inner instruction:", error instanceof Error ? error.message : String(error)); } } } - } - return { - signature: tx.transaction.signatures, - recentBlockhash: tx.transaction.recentBlockhash, - blockTime: serializedBigInt(tx.blockTime), - fee: tx.transaction.fee, - error: tx.meta.err ? 'FAILED' : 'SUCCESS', - status: tx.meta.status, - accounts: accounts, - decodedInstructions, - parsed, - balances: balanceAnalysis, - totalInstructions: instructions.length, - totalAccountsKeys: tx.transaction.message.accountKeys.length, - totalWritableKeys: tx.meta?.loadedAddresses?.writable.length, - totalReadonlyKeys: tx.meta?.loadedAddresses?.readonly.length, - totalInnerInstructions: innerInstructions ? innerInstructions.reduce((sum: number, inst: any) => sum + inst.instructions.length, 0) : 0, - successfullyDecoded: decodedInstructions.length + return { + signature: transactionInfo.transaction.signatures, + recentBlockhash: transactionInfo.transaction.recentBlockhash, + blockTime: serializedBigInt(transactionInfo.blockTime), + fee: transactionInfo.transaction.fee, + error: transactionInfo.meta.err ? 'FAILED' : 'SUCCESS', + status: transactionInfo.meta.status, + accounts: accounts, + decodedInstructions, + parsed, + balances: balanceAnalysis, + totalInstructions: instructions.length, + totalAccountsKeys: transactionInfo.transaction.message.accountKeys.length, + totalWritableKeys: transactionInfo.meta?.loadedAddresses?.writable.length, + totalReadonlyKeys: transactionInfo.meta?.loadedAddresses?.readonly.length, + totalInnerInstructions: innerInstructions ? innerInstructions.reduce((sum: number, inst: InnerInstruction) => sum + inst.instructions.length, 0) : 0, + successfullyDecoded: decodedInstructions.length + } + } catch (error) { + console.log("Error getting transaction info:", error instanceof Error ? error.message : String(error)); + return undefined; } } \ No newline at end of file diff --git a/src/utils/decoder/jupiter/index.ts b/src/utils/decoder/jupiter/index.ts index 5f0ba33..3416a86 100644 --- a/src/utils/decoder/jupiter/index.ts +++ b/src/utils/decoder/jupiter/index.ts @@ -1,4 +1,4 @@ -import { decodeB58Data, serializedBigInt } from "../index.js"; +import { SolanaInstruction, decodeB58Data } from "../"; import { routePlanStepSchema, discriminatorSchema, routePlanLengthSchema, fixedFieldsSchema, swapEventSchema, jupiterSwapFixedSchema } from "./schema.js"; import { getSwapTypeName } from "./swapType.js"; @@ -30,10 +30,9 @@ const decodeRoutePlan = (buffer: Buffer, offset: number, length: number) => { return steps; }; -export const jupiterDecoder = (type: string, programId: string, instruction: any) => { +export const jupiterDecoder = (instruction: SolanaInstruction) => { const dataDecoded = decodeB58Data(instruction.data); const discriminator = dataDecoded[0]; - // console.log("jupiter", discriminator); try { switch (discriminator) { diff --git a/src/utils/extractTrades.ts b/src/utils/extractTrades.ts index 43f65f5..5a262ff 100644 --- a/src/utils/extractTrades.ts +++ b/src/utils/extractTrades.ts @@ -4,6 +4,7 @@ */ import { getSignerTrades, type TokenBalance } from './decoder/extractBalances.js'; +import { TxDecodeResult } from './decoder'; export interface LightweightTrade { type: 'buy' | 'sell' | 'swap'; @@ -50,7 +51,7 @@ function convertBalanceToTrade(balance: TokenBalance): LightweightTrade { * IMPORTANT: Extracts trades from BALANCE CHANGES, not decoded instructions * This ensures we capture ALL trades, even from DEXs we don't have decoders for */ -export function extractTrades(parsedTransaction: any): LightweightTrade[] { +export function extractTrades(parsedTransaction: TxDecodeResult): LightweightTrade[] { if (!parsedTransaction || !parsedTransaction.balances) { return []; } diff --git a/src/utils/parseTrade.ts b/src/utils/parseTrade.ts index d6f1f68..307a03b 100644 --- a/src/utils/parseTrade.ts +++ b/src/utils/parseTrade.ts @@ -1,6 +1,134 @@ -import { decodeTxData } from './decoder/index.js'; -import { getSignerTrades } from './decoder/extractBalances.js'; -import type { BirdeyeService } from '../services/api/birdeyes.js'; +import { decodeTxData, TxDecodeResult } from './decoder'; +import { getSignerTrades, TokenBalance, SolBalance } from './decoder/extractBalances'; +import type { BirdeyeService } from '../services/api/birdeyes'; + +/** + * On déduplique par token+timestamp (arrondi à l'heure) pour éviter les appels dupliqués + */ +interface TradeWithTimestamp { + mint: string; + timestamp: number; + tx: any; + trade: any; +} + +/** + * Price analysis data for a trade + */ +export interface TradePriceAnalysis { + purchasePrice: number; + currentPrice: number; + athPrice: number; + athTimestamp: number; + priceHistoryPoints: number; +} + +/** + * Parsed trade data with price analysis + */ +export interface ParsedTrade { + mint: string; + tokenBalance: TokenBalance; + tradeType: 'increase' | 'decrease' | 'no_change'; + priceAnalysis: TradePriceAnalysis | null; + tokenSymbol: string | undefined; + volume: number; + missedATH: number; + gainLoss: number; +} + +/** + * Parsed transaction with trades and balances + */ +export interface ParsedTransaction { + signature: string[]; + recentBlockhash: string; + blockTime: number; + fee: any; + error: string; + status: any; + accounts: any[]; + balances: { + signerAddress: string; + solBalance: SolBalance | null; + tokenBalances: TokenBalance[]; + }; + trades: ParsedTrade[]; +} + +/** + * Trade summary for best/worst trade + */ +interface TradeSummary { + mint: string; + gainLoss: string; + gainLossUSD: string; + gainLossSOL: string; + signature: string; + blockTime: number; +} + +/** + * Token summary data + */ +interface TokenSummary { + mint: string; + trades: number; + totalTokensTraded: number; + totalVolumeUSD: number; + totalGainLoss: number; + totalMissedATH: number; + bestGainLoss: number; + worstGainLoss: number; + totalPurchasePrice: number; + totalAthPrice: number; + averageGainLoss: number; + averageMissedATH: number; + averageVolumeUSD: number; + averagePurchasePrice: number; + averageAthPrice: number; +} + +/** + * Global summary result + */ +export interface GlobalSummary { + overview: { + totalTransactions: number; + totalTrades: number; + uniqueTokens: number; + profitableTrades: number; + losingTrades: number; + winRate: string; + purchases: number; + sales: number; + noChange: number; + }; + volume: { + totalTokensTraded: string; + totalVolumeUSD: string; + totalVolumeSOL: string; + averageTradeSizeUSD: string; + }; + performance: { + totalGainLoss: string; + averageGainLoss: string; + totalMissedATH: string; + averageMissedATH: string; + }; + bestTrade: TradeSummary | null; + worstTrade: TradeSummary | null; + tokens: TokenSummary[]; +} + +export interface HeliusTransaction { + signature: string; + slot: number; + blockTime: number; + error: string; + memo: string; + confirmationStatus: "finalized" | "confirmed" | "processed"; + } /** * Cache simple pour éviter les appels BirdEye dupliqués @@ -18,14 +146,14 @@ const priceAnalysisCache = new Map(); * @param birdeyeService Birdeye service instance for price analysis * @returns Array of parsed transactions with trades and price analysis */ -export const parseTransactionsWithPriceAnalysis = async (transactions: any[], birdeyeService: BirdeyeService) => { +export const parseTransactionsWithPriceAnalysis = async (transactions: HeliusTransaction[], birdeyeService: BirdeyeService): Promise => { // ÉTAPE 1: Parser toutes les transactions en parallèle const parsedTxsResults = await Promise.all( transactions.map(async (transaction) => { try { const tx = await decodeTxData(transaction); - if (tx.error === 'SUCCESS') { + if (tx && tx.error === 'SUCCESS') { const signerTrades = getSignerTrades(tx.balances); return { tx, @@ -42,18 +170,11 @@ export const parseTransactionsWithPriceAnalysis = async (transactions: any[], bi ); // Filtrer les résultats null - const validParsedTxs = parsedTxsResults.filter((result): result is { tx: any; signerTrades: any[]; hasTrades: boolean } => + const validParsedTxs = parsedTxsResults.filter((result): result is { tx: TxDecodeResult; signerTrades: TokenBalance[]; hasTrades: boolean } => result !== null && result.hasTrades ); // ÉTAPE 2: Collecter tous les trades avec leur token et timestamp - // On déduplique par token+timestamp (arrondi à l'heure) pour éviter les appels dupliqués - interface TradeWithTimestamp { - mint: string; - timestamp: number; - tx: any; - trade: any; - } const tradesToAnalyze: TradeWithTimestamp[] = []; @@ -140,11 +261,11 @@ export const parseTransactionsWithPriceAnalysis = async (transactions: any[], bi ); // ÉTAPE 6: Construire les transactions parsées avec les analyses de prix - const parsedTransactionsArray: any[] = []; + const parsedTransactionsArray: ParsedTransaction[] = []; validParsedTxs.forEach(({ tx, signerTrades }) => { - const trades = signerTrades.map((tokenTrade) => { - const tradeData: any = { + const trades = signerTrades.map((tokenTrade): ParsedTrade => { + const tradeData: ParsedTrade = { mint: tokenTrade.mint, tokenBalance: tokenTrade, tradeType: tokenTrade.changeType, @@ -191,7 +312,7 @@ export const parseTransactionsWithPriceAnalysis = async (transactions: any[], bi }); parsedTransactionsArray.push({ - signature: tx.signature, + signature: Array.isArray(tx.signature) ? tx.signature : [tx.signature], recentBlockhash: tx.recentBlockhash, blockTime: tx.blockTime, fee: tx.fee, @@ -215,7 +336,7 @@ export const parseTransactionsWithPriceAnalysis = async (transactions: any[], bi * @param transactions Parsed transactions with trades and price analysis * @returns Global summary with overview, volume, performance, best/worst trades, and tokens */ -export const calculateGlobalSummary = (transactions: any[]) => { +export const calculateGlobalSummary = (transactions: ParsedTransaction[]): GlobalSummary => { const summary = { totalTransactions: transactions.length, totalTrades: 0,