diff --git a/apps/example-app/src/solder/indexer.ts b/apps/example-app/src/solder/indexer.ts index d2e2812..f7c5986 100644 --- a/apps/example-app/src/solder/indexer.ts +++ b/apps/example-app/src/solder/indexer.ts @@ -40,7 +40,7 @@ const INDEXER_CONFIG: Partial = { databaseUrl: process.env.DATABASE_URL, // 🔧 MODIFY: Use your actual database URL cursorKey: "my-indexer", // 🔧 MODIFY: Use a unique identifier for your indexer - enableUIProgress: true, // 🔧 MODIFY: Set to false to disable progress UI + enableUIProgress: false, // 🔧 MODIFY: Set to false to disable progress UI }; /** @@ -101,11 +101,11 @@ export const initializeIndexer = async () => { ) => { // 🔧 MODIFY: Replace this database insertion with your custom logic await db.insert(tradesTable).values({ // 🔧 MODIFY: Replace tradesTable with your table - mint: event.parsed.mint.toBase58(), + mint: event.parsed.mint, solAmount: event.parsed.sol_amount.toString(), tokenAmount: event.parsed.token_amount.toString(), isBuy: event.parsed.is_buy, - user: event.parsed.user.toBase58(), + user: event.parsed.user, virtualSolReserves: event.parsed.virtual_sol_reserves.toString(), virtualTokenReserves: event.parsed.virtual_token_reserves.toString(), timestamp: new Date(Number(event.parsed.timestamp) * 1000), @@ -113,6 +113,20 @@ export const initializeIndexer = async () => { }, }); + await indexer.onTransactions({ + filterByProgramIds: ["TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"], + filterByInstructions: ["transferChecked"], + handler: async (transaction) => { + console.log( + "Transaction parsed:", + JSON.stringify(transaction, (key, value) => + typeof value === "bigint" ? value.toString() : value, + 2) + ); + }, + }); + + /// start the indexer await indexer.start(); diff --git a/packages/core/src/examples/simple-indexer.ts b/packages/core/src/examples/simple-indexer.ts index b2c4ab7..4ef1055 100644 --- a/packages/core/src/examples/simple-indexer.ts +++ b/packages/core/src/examples/simple-indexer.ts @@ -10,20 +10,31 @@ async function main() { console.log("🚀 Starting Solder Indexer Example"); const indexer = new Indexer({ - startBlock: 300000000, - rpcUrl: "https://solder-solanam-6597.mainnet.rpcpool.com/3b46c479-63d2-4713-8555-49171bd416eb", + startBlock: 379635639, + rpcUrl: "https://api.mainnet-beta.solana.com", databaseUrl: "postgresql://postgres:password123@127.0.0.1:6500/app", cursorKey: "my-indexer", }); console.log("📝 Registering event handlers..."); - + await indexer.onEvent({ programId: "6EF8rrecthR5Dkzon8Nwu78hRvfCKubJ14M5uBEwF6P", idl: PUMP_FUN_IDL as unknown as Idl, eventName: "TradeEvent", handler: async (event) => { console.log("Event parsed:", event); + } + }); + + await indexer.onTransactions({ + handler: async (transaction) => { + console.log( + "Transaction parsed:", + JSON.stringify(transaction, (key, value) => + typeof value === "bigint" ? value.toString() : value, + 2) + ); }, }); diff --git a/packages/core/src/indexer/index.ts b/packages/core/src/indexer/index.ts index edb1db0..4d780fd 100644 --- a/packages/core/src/indexer/index.ts +++ b/packages/core/src/indexer/index.ts @@ -7,4 +7,7 @@ export { type ExtractEventNames, type ExtractEventData, type IndexerEvent, + type IndexerTransaction, + type TransactionHandler, + type OnTransactionConfig, } from "./indexer"; diff --git a/packages/core/src/indexer/indexer.ts b/packages/core/src/indexer/indexer.ts index 983700c..b5b6a32 100644 --- a/packages/core/src/indexer/indexer.ts +++ b/packages/core/src/indexer/indexer.ts @@ -6,6 +6,7 @@ import { CursorStore } from "./db"; import { drizzle, NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { Idl } from "@coral-xyz/anchor"; +import { fetchParsedBlock, forEachInstruction } from "../utils/block"; export interface IndexerConfig { startBlock: number; @@ -49,6 +50,46 @@ export interface OnEventConfig< ) => Promise | void; } +export interface IndexerTransaction { + hash: string; + slot: number; + blockTime: number | null; + blockHash: string; + data: { + block_number: number; + block_hash: string; + block_ts: number | null; + txn_hash: string | undefined; + instructions: Array<{ + index: number; + programId: string; + data: unknown & { + type: string; + info: unknown; + }; + }>; + }; +} + +export interface TransactionHandler { + id: string; + filterByInstructions?: string[]; + filterByProgramIds?: string[]; + handler: ( + transaction: IndexerTransaction, + db: NodePgDatabase> & { $client: Pool }, + ) => Promise | void; +} + +export interface OnTransactionConfig { + filterByInstructions?: string[]; // if provided, only transactions with these instructions will be passed to the handler + filterByProgramIds?: string[]; // if provided, only transactions with these program IDs will be passed to the handler + handler: ( + transaction: IndexerTransaction, + db: NodePgDatabase> & { $client: Pool }, + ) => Promise | void; +} + // Type to extract event names from IDL (supports both legacy and current formats) export type ExtractEventNames = [TIdl] extends [LegacyIdl] @@ -102,6 +143,7 @@ export class Indexer { private readonly rpcClient: RpcClient; private registeredPrograms: Map = new Map(); private eventHandlers: Map> = new Map(); + private transactionHandlers: Map = new Map(); private isRunning: boolean = false; private currentSlot: number; private cursorStore?: CursorStore; @@ -261,6 +303,49 @@ export class Indexer { return Array.from(this.eventHandlers.values()); } + /** + * Register a transaction handler to receive all transactions + * @param config Configuration for the transaction handler + * @returns A function to remove the transaction handler + */ + public async onTransactions( + config: OnTransactionConfig, + ): Promise<() => void> { + let handlerId = `transaction-${Date.now()}`; + + if(config.filterByInstructions) { + handlerId += `-${config.filterByInstructions.sort().join("-")}`; + } + + if(config.filterByProgramIds) { + handlerId += `-${config.filterByProgramIds.sort().join("-")}`; + } + + const transactionHandler: TransactionHandler = { + id: handlerId, + filterByInstructions: config.filterByInstructions, + filterByProgramIds: config.filterByProgramIds, + handler: config.handler, + }; + + this.transactionHandlers.set(handlerId, transactionHandler); + + console.log(`Registered transaction handler`); + + return () => { + this.transactionHandlers.delete(handlerId); + console.log(`Removed transaction handler`); + }; + } + + /** + * Get all registered transaction handlers + */ + getTransactionHandlers(): TransactionHandler[] { + return Array.from(this.transactionHandlers.values()); + } + + /** * Remove all event handlers for a specific program */ @@ -309,6 +394,11 @@ export class Indexer { console.log(`Starting indexer from block ${this.currentSlot}`); console.log(`Monitoring programs:`, this.getRegisteredProgramIds()); + if (this.transactionHandlers.size > 0) { + console.log( + `Monitoring all transactions (${this.transactionHandlers.size} handler(s))`, + ); + } try { await this.processBlocks(); @@ -372,7 +462,9 @@ export class Indexer { const programIds = this.getRegisteredProgramIds(); - if (programIds.length === 0) { + const hasTransactionHandlers = this.transactionHandlers.size > 0; + + if (programIds.length === 0 && !hasTransactionHandlers) { console.log(`No programs registered, skipping block ${slot}`); return; } @@ -389,38 +481,41 @@ export class Indexer { programIdls, }); - if (!blockData) { - console.log(`No block data found for slot ${slot}`); - return; + if (blockData) { + for (const transaction of blockData.transactions) { + for (const eventInfo of transaction.events) { + const startTime = performance.now(); + await this.handleEvent(eventInfo, transaction); + + // Track event stats + const duration = performance.now() - startTime; + const key = `${eventInfo.programId}-${eventInfo.event.name}`; + const existing = this.progressState.eventStats.get(key); + if (existing) { + existing.count++; + existing.totalDuration += duration; + } else { + this.progressState.eventStats.set(key, { + count: 1, + totalDuration: duration, + contractAddress: eventInfo.programId.slice(0, 16), + }); + } + } + } } - console.log( - `Processing block ${slot} with ${blockData.transactions.length} transactions`, - ); - - for (const transaction of blockData.transactions) { - for (const eventInfo of transaction.events) { - const startTime = performance.now(); - await this.handleEvent(eventInfo, transaction); - - // Track event stats - const duration = performance.now() - startTime; - const key = `${eventInfo.programId}-${eventInfo.event.name}`; - const existing = this.progressState.eventStats.get(key); - if (existing) { - existing.count++; - existing.totalDuration += duration; - } else { - this.progressState.eventStats.set(key, { - count: 1, - totalDuration: duration, - contractAddress: eventInfo.programId.slice(0, 16), - }); + + if (hasTransactionHandlers) { + const blockDataForTransactions = await this.rpcClient.getBlockWithInstructions(slot); + if (blockDataForTransactions) { + for (const transaction of blockDataForTransactions.transactions) { + await this.handleTransaction(transaction); } } } - if (this.cursorStore && blockData.block_hash) { + if (this.cursorStore && blockData?.block_hash) { await this.cursorStore.upsertCursor( this.cursorKey, slot, @@ -432,6 +527,98 @@ export class Indexer { } } + /** + * Handle a transaction with instructions for transaction handlers + */ + private async handleTransaction( + transaction: { + block_number: number; + block_hash: string; + block_ts: number | null; + txn_hash: string | undefined; + instructions?: Array<{ + index: number; + programId: string; + parsed: unknown; + }>; + }, + ): Promise { + if (!transaction.instructions || transaction.instructions.length === 0) { + return; + } + + if (!transaction.txn_hash) { + return; + } + + const allHandlers = Array.from(this.transactionHandlers.values()); + + if (allHandlers.length === 0) { + return; + } + + // Convert instructions to match IndexerTransaction format + const instructions = transaction.instructions.map((instr) => ({ + index: instr.index, + programId: instr.programId, + data: instr.parsed, + })); + + + const transactionData: IndexerTransaction = { + hash: transaction.txn_hash, + slot: transaction.block_number, + blockTime: transaction.block_ts, + blockHash: transaction.block_hash, + data: { + block_number: transaction.block_number, + block_hash: transaction.block_hash, + block_ts: transaction.block_ts, + txn_hash: transaction.txn_hash, + instructions: instructions.map(instr => ({ + index: instr.index, + programId: instr.programId, + data: instr.data as { type: string; info: unknown }, + })), + }, + }; + + // Call all transaction handlers + for (const handler of allHandlers) { + try { + + if (handler.filterByInstructions && handler.filterByInstructions.length > 0) { + // Check if any instruction.programId matches any of filterByInstructions + const instructionNames = instructions.map(instr => (instr.data as { type: string }).type); + const hasIntersection = handler.filterByInstructions.some(filterInstructionName => + instructionNames.includes(filterInstructionName) + ); + if (!hasIntersection) { + return; + } + } + + if (handler.filterByProgramIds && handler.filterByProgramIds.length > 0) { + // Check if any instruction.programId matches any of filterByProgramIds + const instructionProgramIds = instructions.map(instr => instr.programId); + const hasIntersection = handler.filterByProgramIds.some(filterProgramId => + instructionProgramIds.includes(filterProgramId) + ); + if (!hasIntersection) { + return; + } + } + + if (!this.db) { + throw new Error("Database not initialized"); + } + await handler.handler(transactionData, this.db); + } catch (error) { + console.error(`Error in transaction handler:`, error); + } + } + } + /** * Handle a decoded event */ @@ -539,12 +726,14 @@ export class Indexer { currentSlot: number; registeredPrograms: number; eventHandlers: number; + transactionHandlers: number; } { return { isRunning: this.isRunning, currentSlot: this.currentSlot, registeredPrograms: this.registeredPrograms.size, eventHandlers: this.eventHandlers.size, + transactionHandlers: this.transactionHandlers.size, }; } @@ -606,3 +795,4 @@ export class Indexer { return remaining / rps; } } + diff --git a/packages/core/src/rpc/rpc.ts b/packages/core/src/rpc/rpc.ts index 2a275ce..e783206 100644 --- a/packages/core/src/rpc/rpc.ts +++ b/packages/core/src/rpc/rpc.ts @@ -89,7 +89,7 @@ export class RpcClient { async getBlockWithInstructions( slot: number, - filter: { + filter?: { programIds: string[]; programIdls?: Map; } @@ -124,12 +124,12 @@ export class RpcClient { const instructions = hasMessage(txn.transaction) ? collectWith( { transaction: txn.transaction, meta: txn.meta! }, - filter, + filter ?? { programIds: [] }, ({ index, programId, instr }) => { if (isParsedInstruction(instr)) { return { index, programId, parsed: instr.parsed }; } - if (isPartiallyDecodedInstruction(instr)) { + if (isPartiallyDecodedInstruction(instr) && filter?.programIdls) { // Use program-specific IDL if provided const programIdl = filter.programIdls?.get(programId); const decoded = decodeInstruction(instr.data, programId, programIdl); diff --git a/packages/core/src/utils/block.ts b/packages/core/src/utils/block.ts index 17ec513..4e546ea 100644 --- a/packages/core/src/utils/block.ts +++ b/packages/core/src/utils/block.ts @@ -91,7 +91,7 @@ export function collectWith( ): T[] { const results: T[] = []; forEachInstruction(txn, ({ index, programId, instr }) => { - if (!filter.programIds.includes(programId)) return; + if (filter.programIds.length > 0 && !filter.programIds.includes(programId)) return; const mapped = mapper({ index, programId, instr }); if (mapped !== null) results.push(mapped); });