diff --git a/src/config/rethProviders.ts b/src/config/rethProviders.ts new file mode 100644 index 0000000..c7f311e --- /dev/null +++ b/src/config/rethProviders.ts @@ -0,0 +1,30 @@ +import { ClientFactory, type EthereumClient } from "@openscan/network-connectors"; + +/** + * Hardcoded reth RPC providers for eth_getTransactionBySenderAndNonce support. + * These are separate from user-configured RPCs and used only for nonce-based tx lookups. + */ +export const RETH_PROVIDER_URLS = [ + "https://eth-pokt.nodies.app", + "https://eth.api.onfinality.io/public", + "https://ethereum.rpc.subquery.network/public", +]; + +/** Chain ID where nonce-based transaction lookup is available */ +export const NONCE_LOOKUP_CHAIN_ID = 1; + +let rethClient: EthereumClient | null = null; + +/** + * Get a singleton EthereumClient configured with reth providers using race strategy. + * Race strategy means the fastest provider wins each call. + */ +export function getRethClient(): EthereumClient { + if (!rethClient) { + rethClient = ClientFactory.createTypedClient(1, { + rpcUrls: RETH_PROVIDER_URLS, + type: "race", + }); + } + return rethClient; +} diff --git a/src/locales/en/address.json b/src/locales/en/address.json index 41f5c47..01f1d2c 100644 --- a/src/locales/en/address.json +++ b/src/locales/en/address.json @@ -183,5 +183,7 @@ "errorResolvingENS": "Error resolving ENS", "unknownError": "Unknown error", "failedToFetchAddressData": "Failed to fetch address data", - "nonce": "Nonce (Transactions Sent)" + "nonce": "Nonce (Transactions Sent)", + "fetchingSentTxsByNonce": "Fetching sent transactions ({{current}}/{{total}})...", + "searchingReceivedTxs": "Searching for received transactions..." } diff --git a/src/locales/es/address.json b/src/locales/es/address.json index a8ead65..3038989 100644 --- a/src/locales/es/address.json +++ b/src/locales/es/address.json @@ -183,5 +183,7 @@ "errorResolvingENS": "Error al resolver ENS", "unknownError": "Error desconocido", "failedToFetchAddressData": "No se pudieron obtener los datos de la dirección", - "nonce": "Nonce (transacciones enviadas)" + "nonce": "Nonce (transacciones enviadas)", + "fetchingSentTxsByNonce": "Obteniendo transacciones enviadas ({{current}}/{{total}})...", + "searchingReceivedTxs": "Buscando transacciones recibidas..." } diff --git a/src/services/AddressTransactionSearch.ts b/src/services/AddressTransactionSearch.ts index 9cb528e..4e04223 100644 --- a/src/services/AddressTransactionSearch.ts +++ b/src/services/AddressTransactionSearch.ts @@ -6,6 +6,7 @@ import type { import type { Transaction } from "../types"; import { logger } from "../utils/logger"; import { hexToNumber } from "./adapters/EVMAdapter/utils"; +import type { NonceLookupService } from "./NonceLookupService"; interface Semaphore { acquire(): Promise<() => void>; @@ -90,6 +91,7 @@ function extractData(data: T | T[] | null | undefined): T | null { export class AddressTransactionSearch { private client: EthereumClient; + private nonceLookup: NonceLookupService | null; private nonceCache: Map = new Map(); private balanceCache: Map = new Map(); private rpcSemaphore: Semaphore = createSemaphore(4); @@ -98,9 +100,11 @@ export class AddressTransactionSearch { // Can be increased later when RPC type detection is added (e.g., for localhost/private RPCs) private static readonly MAX_SEGMENTS = 8; // Max segments per split (safe for public RPCs) private static readonly BATCH_SIZE = 8; // Max parallel state fetches (8 blocks = 16 RPC calls) + private static readonly RECEIPT_BATCH_SIZE = 20; - constructor(client: EthereumClient) { + constructor(client: EthereumClient, nonceLookup?: NonceLookupService) { this.client = client; + this.nonceLookup = nonceLookup ?? null; } private rpcLimited(fn: () => Promise): Promise { @@ -544,6 +548,394 @@ export class AddressTransactionSearch { return transactions; } + /** + * Optimized search using eth_getTransactionBySenderAndNonce for sent transactions, + * with gap-based binary search for received/internal transactions. + * + * Phase 1: Nonce lookups (reth) + receipt fetches (user RPC) in parallel + * Phase 2: Gap searches for received/internal txs in intervals between sent tx blocks + * Phase 3: Combine, sort, deliver as one batch + * + * Returns null if it should fall back to binary search. + */ + private async searchWithNonceLookup( + normalizedAddress: string, + endBlock: number, + initialState: AddressState, + finalState: AddressState, + options: { + limit: number; + onProgress?: ProgressCallback; + onTransactionsFound?: TransactionFoundCallback; + signal?: AbortSignal; + }, + ): Promise { + const { limit, onProgress, onTransactionsFound, signal } = options; + const nonceLookup = this.nonceLookup; + if (!nonceLookup) return null; + + const searchStartTime = performance.now(); + const nonceDelta = finalState.nonce - initialState.nonce; + + // Determine nonce range to fetch + const totalNoncesToFetch = limit > 0 ? Math.min(limit, nonceDelta) : nonceDelta; + const startNonce = finalState.nonce - totalNoncesToFetch; + const endNonce = finalState.nonce; + + // Probe availability with the first nonce in range + const available = await nonceLookup.isAvailable(normalizedAddress, startNonce); + if (!available) return null; + + // Phase 1: Fetch sent transactions via nonce lookup + receipts in parallel + const sentTxs: Array = []; + const sentBlockNumbers = new Set(); + let completedNonces = 0; + + onProgress?.({ + phase: "fetching", + current: 0, + total: totalNoncesToFetch, + message: `Fetching sent transactions (0/${totalNoncesToFetch})...`, + }); + + const nonceResults = await nonceLookup.fetchSentTransactions( + normalizedAddress, + startNonce, + endNonce, + signal, + (completed, total) => { + completedNonces = completed; + onProgress?.({ + phase: "fetching", + current: completed, + total, + message: `Fetching sent transactions (${completed}/${total})...`, + }); + }, + ); + + if (signal?.aborted) return null; + + // Fetch receipts and full tx data for nonce lookup results + // These go to the user's RPC (not reth), so they can run concurrently + for (let i = 0; i < nonceResults.length; i += AddressTransactionSearch.RECEIPT_BATCH_SIZE) { + if (signal?.aborted) break; + const batch = nonceResults.slice(i, i + AddressTransactionSearch.RECEIPT_BATCH_SIZE); + + const txPromises = batch.map(async (nr) => { + try { + const [txResult, receiptResult] = await Promise.all([ + this.rpcLimited(() => this.client.getTransactionByHash(nr.txHash)), + this.rpcLimited(() => this.client.getTransactionReceipt(nr.txHash)), + ]); + + const tx = extractData(txResult.data); + const receipt = extractData(receiptResult.data); + if (!tx) return null; + + // Also get block timestamp + let timestamp = ""; + try { + const blockResult = await this.rpcLimited(() => + this.client.getBlockByNumber(`0x${nr.blockNumber.toString(16)}`, false), + ); + const block = extractData(blockResult.data); + if (block) timestamp = block.timestamp; + } catch { + // timestamp remains empty + } + + return { + hash: tx.hash, + blockNumber: tx.blockNumber || "", + blockHash: tx.blockHash || "", + from: tx.from, + to: tx.to || "", + value: tx.value, + gas: tx.gas, + gasPrice: tx.gasPrice || "0x0", + maxFeePerGas: tx.maxFeePerGas, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + data: tx.input, + input: tx.input, + nonce: tx.nonce, + transactionIndex: tx.transactionIndex || "0x0", + timestamp, + type: "sent" as const, + v: tx.v || "0x0", + r: tx.r || "0x0", + s: tx.s || "0x0", + receipt: receipt || undefined, + } as Transaction & { type: "sent" | "received" | "internal" }; + } catch (err) { + logger.warn(`Failed to fetch tx details for ${nr.txHash}:`, err); + return null; + } + }); + + const results = await Promise.all(txPromises); + for (const tx of results) { + if (tx) { + sentTxs.push(tx); + const blockNum = hexToNumber(tx.blockNumber || "0x0"); + if (blockNum > 0) sentBlockNumbers.add(blockNum); + } + } + } + + if (signal?.aborted) return null; + + // Phase 2: Interleaved progressive delivery — walk from newest to oldest, + // alternating gap searches and sent tx delivery so the UI appends in order. + onProgress?.({ + phase: "searching", + current: completedNonces, + total: totalNoncesToFetch, + message: "Searching for received transactions...", + }); + + const allTransactions: Array = []; + + if (sentBlockNumbers.size === 0) { + // No sent txs found — fall back to binary search (return null) + return null; + } + + // Sort sent txs by block number descending (newest first) and index by block + sentTxs.sort((a, b) => { + const blockA = hexToNumber(a.blockNumber || "0x0"); + const blockB = hexToNumber(b.blockNumber || "0x0"); + return blockB - blockA; + }); + const sentTxsByBlock = new Map< + number, + Array + >(); + for (const tx of sentTxs) { + const blockNum = hexToNumber(tx.blockNumber || "0x0"); + const existing = sentTxsByBlock.get(blockNum); + if (existing) { + existing.push(tx); + } else { + sentTxsByBlock.set(blockNum, [tx]); + } + } + + // Build ordered segments: [gap, sentTx, gap, sentTx, ...] newest to oldest + // Each segment is either a gap to search or a sent tx block to deliver + type Segment = { kind: "gap"; from: number; to: number } | { kind: "sent"; blockNum: number }; + + const segments: Segment[] = []; + const sortedDesc = Array.from(sentBlockNumbers).sort((a, b) => b - a); + const newestSent = sortedDesc[0] as number; + + // Gap after newest sent tx (contains the most recent possible received txs) + if (newestSent < endBlock) { + segments.push({ kind: "gap", from: newestSent + 1, to: endBlock }); + } + + // Interleave sent txs and gaps between them, newest to oldest + for (let i = 0; i < sortedDesc.length; i++) { + const current = sortedDesc[i] as number; + segments.push({ kind: "sent", blockNum: current }); + + if (i < sortedDesc.length - 1) { + const older = sortedDesc[i + 1] as number; + if (current - older > 1) { + segments.push({ kind: "gap", from: older + 1, to: current - 1 }); + } + } + } + + const processedBlocks = new Set(); + + // Process segments in order — each delivery is chronologically after the previous + for (const segment of segments) { + if (signal?.aborted) break; + if (limit > 0 && allTransactions.length >= limit) break; + + if (segment.kind === "sent") { + // Deliver sent tx(s) at this block + const txsAtBlock = sentTxsByBlock.get(segment.blockNum); + if (txsAtBlock && txsAtBlock.length > 0) { + allTransactions.push(...txsAtBlock); + onTransactionsFound?.(txsAtBlock); + } + continue; + } + + // Gap search + const gap = segment; + try { + const gapEndState = await this.getState(normalizedAddress, gap.to); + const gapStartState = await this.getState(normalizedAddress, gap.from); + + if ( + gapStartState.balance === gapEndState.balance && + gapStartState.nonce === gapEndState.nonce + ) { + continue; // No activity in this gap + } + + // Exponential narrowing for large gaps + let narrowedFrom = gap.from; + let narrowedTo = gap.to; + const gapSize = gap.to - gap.from; + + if (gapSize > 100_000) { + let range = 100_000; + let prevBoundary = gap.to; + + while (range < gapSize) { + if (signal?.aborted) break; + const probe = Math.max(gap.to - range, gap.from); + const probeState = await this.getState(normalizedAddress, probe); + + if ( + probeState.nonce !== gapEndState.nonce || + probeState.balance !== gapEndState.balance + ) { + narrowedFrom = probe; + narrowedTo = prevBoundary; + break; + } + + if (probe === gap.from) break; + prevBoundary = probe; + range *= 2; + } + + const narrowedStartState = await this.getState(normalizedAddress, narrowedFrom); + const narrowedEndState = await this.getState(normalizedAddress, narrowedTo); + if ( + narrowedStartState.balance === narrowedEndState.balance && + narrowedStartState.nonce === narrowedEndState.nonce + ) { + continue; + } + } + + // Adaptive segmented search — delivers txs progressively via onTransactionsFound + const searchGap = async ( + searchStart: number, + searchEnd: number, + sState: AddressState, + eState: AddressState, + depth = 0, + ): Promise => { + if (signal?.aborted) return; + + if (searchEnd - searchStart <= 1) { + const nonceChanged = sState.nonce !== eState.nonce; + const balanceChanged = sState.balance !== eState.balance; + + if ((nonceChanged || balanceChanged) && !processedBlocks.has(searchEnd)) { + processedBlocks.add(searchEnd); + const searchForInternal = balanceChanged && !nonceChanged; + const blockTxs = await this.fetchBlockTransactions( + searchEnd, + normalizedAddress, + searchForInternal, + signal, + ); + if (blockTxs.length > 0) { + allTransactions.push(...blockTxs); + onTransactionsFound?.(blockTxs); + } + } + return; + } + + const blockRange = searchEnd - searchStart; + const segmentCount = this.getOptimalSegmentCount(sState, eState, blockRange); + if (segmentCount === 0) return; + + if (segmentCount <= 2) { + const midBlock = Math.floor((searchStart + searchEnd) / 2); + const midState = await this.getState(normalizedAddress, midBlock); + + const rightChanged = + midState.nonce !== eState.nonce || midState.balance !== eState.balance; + const leftChanged = + sState.nonce !== midState.nonce || sState.balance !== midState.balance; + + if (rightChanged) { + await searchGap(midBlock, searchEnd, midState, eState, depth + 1); + } + if (leftChanged) { + await searchGap(searchStart, midBlock, sState, midState, depth + 1); + } + return; + } + + const boundaries = this.calculateBoundaries(searchStart, searchEnd, segmentCount); + const internalBlocks = boundaries.slice(1, -1); + const stateMap = await this.getStatesInBatches(normalizedAddress, internalBlocks); + stateMap.set(searchStart, sState); + stateMap.set(searchEnd, eState); + + for (let i = segmentCount - 1; i >= 0; i--) { + if (signal?.aborted) break; + const segStart = boundaries[i]; + const segEnd = boundaries[i + 1]; + if (segStart === undefined || segEnd === undefined) continue; + + const segStartState = stateMap.get(segStart); + const segEndState = stateMap.get(segEnd); + if (!segStartState || !segEndState) continue; + + const hasChanges = + segStartState.nonce !== segEndState.nonce || + segStartState.balance !== segEndState.balance; + + if (hasChanges) { + await searchGap(segStart, segEnd, segStartState, segEndState, depth + 1); + } + } + }; + + const narrowedStartState = await this.getState(normalizedAddress, narrowedFrom); + const narrowedEndState = await this.getState(normalizedAddress, narrowedTo); + await searchGap(narrowedFrom, narrowedTo, narrowedStartState, narrowedEndState); + } catch (err) { + logger.warn(`Gap search failed for blocks ${gap.from}-${gap.to}:`, err); + } + } + + // Sort all collected transactions by block number descending (newest first) + allTransactions.sort((a, b) => { + const blockA = hexToNumber(a.blockNumber || "0x0"); + const blockB = hexToNumber(b.blockNumber || "0x0"); + return blockB - blockA; + }); + + // Apply limit + const limitedTxs = limit > 0 ? allTransactions.slice(0, limit) : allTransactions; + + const sentCount = limitedTxs.filter((t) => t.type === "sent").length; + const receivedCount = limitedTxs.filter((t) => t.type === "received").length; + const internalCount = limitedTxs.filter((t) => t.type === "internal").length; + const elapsedMs = Math.round(performance.now() - searchStartTime); + + const foundBlocks = new Set( + limitedTxs.map((tx) => hexToNumber(tx.blockNumber || "0x0")).filter((b) => b > 0), + ); + + return { + blocks: Array.from(foundBlocks).sort((a, b) => b - a), + transactions: limitedTxs, + stats: { + totalBlocks: foundBlocks.size, + totalTxs: limitedTxs.length, + sentCount, + receivedCount, + internalCount, + rpcCalls: this.nonceCache.size + this.balanceCache.size, + elapsedMs, + }, + }; + } + async searchAddressActivity( address: string, options: { @@ -576,8 +968,24 @@ export class AddressTransactionSearch { // Get initial and final states // If fromBlock is provided, use it as the lower bound instead of block 0 const startBlock = fromBlock !== undefined ? fromBlock : 0; - const initialState = await this.getState(address, startBlock); - const finalState = await this.getState(address, endBlock); + const initialState = await this.getState(normalizedAddress, startBlock); + const finalState = await this.getState(normalizedAddress, endBlock); + + // Try nonce-based lookup for sent transactions (Ethereum mainnet only) + if (this.nonceLookup && finalState.nonce > initialState.nonce) { + try { + const result = await this.searchWithNonceLookup( + normalizedAddress, + endBlock, + initialState, + finalState, + { limit, onProgress, onTransactionsFound, signal }, + ); + if (result) return result; + } catch (err) { + logger.warn("Nonce lookup failed, falling back to binary search:", err); + } + } // No activity if states are identical and nonce is 0 if ( @@ -687,7 +1095,7 @@ export class AddressTransactionSearch { // For binary (2 segments), use optimized path if (segmentCount <= 2) { const midBlock = Math.floor((searchStartBlock + searchEndBlock) / 2); - const midState = await this.getState(address, midBlock); + const midState = await this.getState(normalizedAddress, midBlock); const leftChanged = searchStartState.nonce !== midState.nonce || @@ -711,7 +1119,7 @@ export class AddressTransactionSearch { // Calculate boundaries and fetch internal boundary states in batches const boundaries = this.calculateBoundaries(searchStartBlock, searchEndBlock, segmentCount); const internalBlocks = boundaries.slice(1, -1); // Exclude start and end (already known) - const stateMap = await this.getStatesInBatches(address, internalBlocks); + const stateMap = await this.getStatesInBatches(normalizedAddress, internalBlocks); // Add known start/end states stateMap.set(searchStartBlock, searchStartState); diff --git a/src/services/NonceLookupService.ts b/src/services/NonceLookupService.ts new file mode 100644 index 0000000..a6997d4 --- /dev/null +++ b/src/services/NonceLookupService.ts @@ -0,0 +1,140 @@ +import type { EthereumClient } from "@openscan/network-connectors"; +import { logger } from "../utils/logger"; + +/** + * Extract data from strategy result, handling both fallback and parallel modes + */ +function extractData(data: T | T[] | null | undefined): T | null { + if (data === null || data === undefined) return null; + if (Array.isArray(data)) return data[0] ?? null; + return data; +} + +export interface NonceLookupResult { + nonce: number; + txHash: string; + blockNumber: number; +} + +/** + * Service for looking up transactions by sender address + nonce using + * the eth_getTransactionBySenderAndNonce RPC method (supported by reth clients). + * + * This allows O(N) discovery of sent transactions where N = address nonce, + * instead of the O(log(blocks) * changes) binary search approach. + */ +export class NonceLookupService { + private client: EthereumClient; + private static readonly BATCH_SIZE = 8; + + constructor(client: EthereumClient) { + this.client = client; + } + + /** + * Probe whether the RPC method is available by making a single test call. + * Returns true if the method responds (even with null for unused nonce). + */ + async isAvailable(address: string, nonce: number): Promise { + try { + const nonceHex = `0x${nonce.toString(16)}`; + const result = await this.client.execute( + "eth_getTransactionBySenderAndNonce", + [address, nonceHex], + ); + // Method is available if we got a successful response (even if data is null) + return result.success; + } catch { + return false; + } + } + + /** + * Fetch sent transactions for a range of nonces. + * Iterates in batches, calling eth_getTransactionBySenderAndNonce for each nonce. + * Individual failures are silently skipped. + * + * @param address - Sender address + * @param startNonce - First nonce to fetch (inclusive) + * @param endNonce - Last nonce to fetch (exclusive) + * @param signal - Optional AbortSignal for cancellation + * @param onBatchComplete - Optional callback after each batch completes + * @returns Array of successful lookup results + */ + async fetchSentTransactions( + address: string, + startNonce: number, + endNonce: number, + signal?: AbortSignal, + onBatchComplete?: (completed: number, total: number) => void, + ): Promise { + const results: NonceLookupResult[] = []; + const total = endNonce - startNonce; + + for (let i = startNonce; i < endNonce; i += NonceLookupService.BATCH_SIZE) { + if (signal?.aborted) break; + + const batchEnd = Math.min(i + NonceLookupService.BATCH_SIZE, endNonce); + const batchPromises: Promise[] = []; + + for (let nonce = i; nonce < batchEnd; nonce++) { + batchPromises.push(this.fetchSingleNonce(address, nonce)); + } + + const batchResults = await Promise.all(batchPromises); + for (const result of batchResults) { + if (result) { + results.push(result); + } + } + + onBatchComplete?.(Math.min(batchEnd - startNonce, total), total); + } + + return results; + } + + /** + * Convenience wrapper that fetches only the last N nonces. + * Used for limit-based searches (e.g., "last 5 transactions"). + */ + async fetchRecentSentTransactions( + address: string, + currentNonce: number, + count: number, + signal?: AbortSignal, + onBatchComplete?: (completed: number, total: number) => void, + ): Promise { + const startNonce = Math.max(0, currentNonce - count); + return this.fetchSentTransactions(address, startNonce, currentNonce, signal, onBatchComplete); + } + + /** + * Fetch a single transaction by sender + nonce. + * Returns null on failure (tolerates partial failures). + */ + private async fetchSingleNonce( + address: string, + nonce: number, + ): Promise { + try { + const nonceHex = `0x${nonce.toString(16)}`; + const result = await this.client.execute<{ hash: string; blockNumber: string } | null>( + "eth_getTransactionBySenderAndNonce", + [address, nonceHex], + ); + + const data = extractData(result.data); + if (!data || !data.hash || !data.blockNumber) return null; + + return { + nonce, + txHash: data.hash, + blockNumber: Number.parseInt(data.blockNumber, 16), + }; + } catch (err) { + logger.warn(`Nonce lookup failed for ${address} nonce ${nonce}:`, err); + return null; + } + } +} diff --git a/src/services/adapters/EVMAdapter/EVMAdapter.ts b/src/services/adapters/EVMAdapter/EVMAdapter.ts index 8b64261..9e4ce6f 100644 --- a/src/services/adapters/EVMAdapter/EVMAdapter.ts +++ b/src/services/adapters/EVMAdapter/EVMAdapter.ts @@ -12,6 +12,8 @@ import { extractData } from "../shared/extractData"; import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; import type { EthereumClient, SupportedChainId } from "@openscan/network-connectors"; +import { getRethClient, NONCE_LOOKUP_CHAIN_ID } from "../../../config/rethProviders"; +import { NonceLookupService } from "../../NonceLookupService"; /** * EVM-compatible blockchain service @@ -23,7 +25,14 @@ export class EVMAdapter extends NetworkAdapter { constructor(networkId: SupportedChainId | 11155111 | 97 | 31337, client: EthereumClient) { super(networkId); this.client = client; - this.initTxSearch(client); + + if (networkId === NONCE_LOOKUP_CHAIN_ID) { + const rethClient = getRethClient(); + const nonceLookup = new NonceLookupService(rethClient); + this.initTxSearch(client, nonceLookup); + } else { + this.initTxSearch(client); + } } protected getClient(): EthereumClient { diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index ac0fe2c..73a787f 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -11,6 +11,7 @@ import type { import { logger } from "../../utils/logger"; import { extractData } from "./shared/extractData"; import { AddressTransactionSearch } from "../AddressTransactionSearch"; +import type { NonceLookupService } from "../NonceLookupService"; export type BlockTag = "latest" | "earliest" | "pending" | "finalized" | "safe"; export type BlockNumberOrTag = number | string | BlockTag; @@ -65,8 +66,8 @@ export abstract class NetworkAdapter { * Initialize the transaction search service * Call this in subclass constructors to enable binary search tx discovery */ - protected initTxSearch(client: EthereumClient): void { - this.txSearch = new AddressTransactionSearch(client); + protected initTxSearch(client: EthereumClient, nonceLookup?: NonceLookupService): void { + this.txSearch = new AddressTransactionSearch(client, nonceLookup); } /**