From 0211adf28fa279b036498a0b016c014f091a2b75 Mon Sep 17 00:00:00 2001 From: Kyle Thornton Date: Thu, 21 Aug 2025 11:19:22 -0700 Subject: [PATCH 1/5] Adding a new JsonRpcProvider subclass supporting erigon's ots_ (Otterscan) methods If your node supports the ots_ namespace, you'll have access to 11 new RPC methods: ots_getApiLevel, ots_hasCode, ots_getInternalOperations, ots_getTransactionError, ots_traceTransaction, ots_getBlockDetails, ots_getBlockTransactions, ots_searchTransactionsBefore/After, ots_getTransactionBySenderAndNonce, ots_getContractCreator - Convenience methods: getTransactionRevertReason() (decodes revert data), ensureOts() (making sure you're running an ots_ compatible node), iterateAddressHistory() (async iterator for pagination) - Full TypeScript support: Proper interfaces for all return types (OtsInternalOp, OtsBlockDetails, etc.) - 16 new unit tests covering all functionality --- src.ts/_tests/test-providers-otterscan.ts | 273 ++++++++++++++++++ src.ts/ethers.ts | 6 +- src.ts/providers/index.ts | 11 + src.ts/providers/provider-otterscan.ts | 321 ++++++++++++++++++++++ 4 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 src.ts/_tests/test-providers-otterscan.ts create mode 100644 src.ts/providers/provider-otterscan.ts diff --git a/src.ts/_tests/test-providers-otterscan.ts b/src.ts/_tests/test-providers-otterscan.ts new file mode 100644 index 0000000000..423776ebfe --- /dev/null +++ b/src.ts/_tests/test-providers-otterscan.ts @@ -0,0 +1,273 @@ +import assert from "assert"; + +import { + FetchRequest, + OtterscanProvider, + JsonRpcProvider, + type OtsInternalOp, + type OtsBlockDetails, + type OtsBlockTxPage, + type OtsSearchPage, + type OtsContractCreator, +} from "../index.js"; + +describe("Test Otterscan Provider", function() { + // Mock OTS responses for testing + function createMockOtsProvider() { + const req = new FetchRequest("http://localhost:8545/"); + + req.getUrlFunc = async (_req, signal) => { + const bodyStr = typeof _req.body === "string" ? _req.body : new TextDecoder().decode(_req.body || new Uint8Array()); + const request = JSON.parse(bodyStr || "{}"); + + let result: any; + + switch (request.method) { + case "ots_getApiLevel": + result = 8; + break; + case "ots_hasCode": + // Mock: return true for non-zero addresses + result = request.params[0] !== "0x0000000000000000000000000000000000000000"; + break; + case "ots_getInternalOperations": + result = [{ + type: 0, + from: "0x1234567890123456789012345678901234567890", + to: "0x0987654321098765432109876543210987654321", + value: "0x1000000000000000000" + }]; + break; + case "ots_getTransactionError": + result = "0x"; + break; + case "ots_traceTransaction": + result = { calls: [] }; + break; + case "ots_getBlockDetails": + result = { + block: { + hash: "0x123abc", + number: "0x1000" + }, + transactionCount: 5, + totalFees: "0x5000000000000000" + }; + break; + case "ots_getBlockTransactions": + result = { + transactions: [{ + hash: "0x456def", + from: "0x1111111111111111111111111111111111111111", + to: "0x2222222222222222222222222222222222222222", + value: "0x1000000000000000000" + }], + receipts: [{ + status: "0x1", + gasUsed: "0x5208" + }] + }; + break; + case "ots_searchTransactionsBefore": + case "ots_searchTransactionsAfter": + result = { + txs: [{ + hash: "0x789ghi", + blockNumber: "0x1000" + }], + receipts: [{ + status: "0x1" + }], + firstPage: true, + lastPage: false + }; + break; + case "ots_getTransactionBySenderAndNonce": + result = "0xabcdef123456789"; + break; + case "ots_getContractCreator": + result = { + hash: "0x987654321", + creator: "0x1111111111111111111111111111111111111111" + }; + break; + case "eth_chainId": + result = "0x1"; + break; + case "eth_blockNumber": + result = "0x1000"; + break; + default: + throw new Error(`Unsupported method: ${request.method}`); + } + + const response = { + id: request.id, + jsonrpc: "2.0", + result + }; + + return { + statusCode: 200, + statusMessage: "OK", + headers: { "content-type": "application/json" }, + body: new TextEncoder().encode(JSON.stringify(response)) + }; + }; + + return new OtterscanProvider(req, 1, { staticNetwork: true }); + } + + it("should extend JsonRpcProvider", function() { + const provider = createMockOtsProvider(); + assert(provider instanceof OtterscanProvider, "should be OtterscanProvider instance"); + assert(provider instanceof JsonRpcProvider, "should extend JsonRpcProvider"); + }); + + it("should get OTS API level", async function() { + const provider = createMockOtsProvider(); + const apiLevel = await provider.otsApiLevel(); + assert.strictEqual(apiLevel, 8, "should return API level 8"); + }); + + it("should check if address has code", async function() { + const provider = createMockOtsProvider(); + + const hasCodeTrue = await provider.hasCode("0x1234567890123456789012345678901234567890"); + assert.strictEqual(hasCodeTrue, true, "should return true for non-zero address"); + + const hasCodeFalse = await provider.hasCode("0x0000000000000000000000000000000000000000"); + assert.strictEqual(hasCodeFalse, false, "should return false for zero address"); + }); + + it("should get internal operations", async function() { + const provider = createMockOtsProvider(); + const internalOps = await provider.getInternalOperations("0x123"); + + assert(Array.isArray(internalOps), "should return array"); + assert.strictEqual(internalOps.length, 1, "should have one operation"); + + const op = internalOps[0]; + assert.strictEqual(op.type, 0, "should have type 0"); + assert.strictEqual(op.from, "0x1234567890123456789012345678901234567890", "should have correct from"); + assert.strictEqual(op.to, "0x0987654321098765432109876543210987654321", "should have correct to"); + assert.strictEqual(op.value, "0x1000000000000000000", "should have correct value"); + }); + + it("should get transaction error data", async function() { + const provider = createMockOtsProvider(); + const errorData = await provider.getTransactionErrorData("0x123"); + assert.strictEqual(errorData, "0x", "should return empty error data"); + }); + + it("should get transaction revert reason", async function() { + const provider = createMockOtsProvider(); + const revertReason = await provider.getTransactionRevertReason("0x123"); + assert.strictEqual(revertReason, null, "should return null for no error"); + }); + + it("should trace transaction", async function() { + const provider = createMockOtsProvider(); + const trace = await provider.traceTransaction("0x123"); + assert(typeof trace === "object", "should return trace object"); + assert(Array.isArray(trace.calls), "should have calls array"); + }); + + it("should get block details", async function() { + const provider = createMockOtsProvider(); + const blockDetails = await provider.getBlockDetails(4096); + + assert(typeof blockDetails === "object", "should return object"); + assert.strictEqual(blockDetails.transactionCount, 5, "should have transaction count"); + assert.strictEqual(blockDetails.totalFees, "0x5000000000000000", "should have total fees"); + assert(blockDetails.block, "should have block data"); + }); + + it("should get block transactions", async function() { + const provider = createMockOtsProvider(); + const blockTxs = await provider.getBlockTransactions(4096, 0, 10); + + assert(Array.isArray(blockTxs.transactions), "should have transactions array"); + assert(Array.isArray(blockTxs.receipts), "should have receipts array"); + assert.strictEqual(blockTxs.transactions.length, 1, "should have one transaction"); + assert.strictEqual(blockTxs.receipts.length, 1, "should have one receipt"); + }); + + it("should search transactions before", async function() { + const provider = createMockOtsProvider(); + const searchResults = await provider.searchTransactionsBefore("0x123", 4096, 10); + + assert(Array.isArray(searchResults.txs), "should have txs array"); + assert(Array.isArray(searchResults.receipts), "should have receipts array"); + assert.strictEqual(searchResults.firstPage, true, "should be first page"); + assert.strictEqual(searchResults.lastPage, false, "should not be last page"); + }); + + it("should search transactions after", async function() { + const provider = createMockOtsProvider(); + const searchResults = await provider.searchTransactionsAfter("0x123", 4096, 10); + + assert(Array.isArray(searchResults.txs), "should have txs array"); + assert(Array.isArray(searchResults.receipts), "should have receipts array"); + }); + + it("should get transaction by sender and nonce", async function() { + const provider = createMockOtsProvider(); + const txHash = await provider.getTransactionBySenderAndNonce("0x123", 0); + assert.strictEqual(txHash, "0xabcdef123456789", "should return transaction hash"); + }); + + it("should get contract creator", async function() { + const provider = createMockOtsProvider(); + const creator = await provider.getContractCreator("0x123"); + + assert(typeof creator === "object", "should return object"); + assert.strictEqual(creator?.hash, "0x987654321", "should have creation hash"); + assert.strictEqual(creator?.creator, "0x1111111111111111111111111111111111111111", "should have creator address"); + }); + + it("should ensure OTS capability", async function() { + const provider = createMockOtsProvider(); + + // Should not throw + await provider.ensureOts(8); + + // Should throw for higher requirement + try { + await provider.ensureOts(10); + assert.fail("should have thrown for unsupported API level"); + } catch (error: any) { + assert(error.message.includes("ots_getApiLevel"), "should mention API level"); + assert.strictEqual(error.code, "OTS_UNAVAILABLE", "should have correct error code"); + } + }); + + it("should have async iterator for address history", function() { + const provider = createMockOtsProvider(); + const iterator = provider.iterateAddressHistory("0x123", "before", 4096); + + assert(typeof iterator[Symbol.asyncIterator] === "function", "should be async iterable"); + }); + + it("should properly type return values", async function() { + const provider = createMockOtsProvider(); + + // Test TypeScript typing works correctly + const apiLevel: number = await provider.otsApiLevel(); + const hasCode: boolean = await provider.hasCode("0x123"); + const internalOps: OtsInternalOp[] = await provider.getInternalOperations("0x123"); + const blockDetails: OtsBlockDetails = await provider.getBlockDetails(4096); + const blockTxs: OtsBlockTxPage = await provider.getBlockTransactions(4096, 0, 10); + const searchResults: OtsSearchPage = await provider.searchTransactionsBefore("0x123", 4096, 10); + const creator: OtsContractCreator | null = await provider.getContractCreator("0x123"); + + // Basic type assertions + assert.strictEqual(typeof apiLevel, "number"); + assert.strictEqual(typeof hasCode, "boolean"); + assert(Array.isArray(internalOps)); + assert(typeof blockDetails === "object"); + assert(typeof blockTxs === "object" && Array.isArray(blockTxs.transactions)); + assert(typeof searchResults === "object" && Array.isArray(searchResults.txs)); + assert(creator === null || typeof creator === "object"); + }); +}); \ No newline at end of file diff --git a/src.ts/ethers.ts b/src.ts/ethers.ts index a88b6331fa..843cf6e05c 100644 --- a/src.ts/ethers.ts +++ b/src.ts/ethers.ts @@ -66,7 +66,7 @@ export { AbstractProvider, FallbackProvider, - JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner, + JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner, OtterscanProvider, BrowserProvider, @@ -175,7 +175,9 @@ export type { ContractRunner, DebugEventBrowserProvider, Eip1193Provider, Eip6963ProviderInfo, EventFilter, Filter, FilterByBlockHash, GasCostParameters, JsonRpcApiProviderOptions, JsonRpcError, - JsonRpcPayload, JsonRpcResult, JsonRpcTransactionRequest, LogParams, + JsonRpcPayload, JsonRpcResult, JsonRpcTransactionRequest, + Hex, OtsInternalOp, OtsBlockDetails, OtsBlockTxPage, OtsSearchPage, OtsContractCreator, + LogParams, MinedBlock, MinedTransactionResponse, Networkish, OrphanFilter, PerformActionFilter, PerformActionRequest, PerformActionTransaction, PreparedTransactionRequest, ProviderEvent, Subscriber, Subscription, diff --git a/src.ts/providers/index.ts b/src.ts/providers/index.ts index 27a5460e2c..6a4be059c0 100644 --- a/src.ts/providers/index.ts +++ b/src.ts/providers/index.ts @@ -57,6 +57,8 @@ export { export { FallbackProvider } from "./provider-fallback.js"; export { JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner } from "./provider-jsonrpc.js" +export { OtterscanProvider } from "./provider-otterscan.js"; + export { BrowserProvider } from "./provider-browser.js"; export { AlchemyProvider } from "./provider-alchemy.js"; @@ -127,6 +129,15 @@ export type { JsonRpcTransactionRequest, } from "./provider-jsonrpc.js"; +export type { + Hex, + OtsInternalOp, + OtsBlockDetails, + OtsBlockTxPage, + OtsSearchPage, + OtsContractCreator +} from "./provider-otterscan.js"; + export type { WebSocketCreator, WebSocketLike } from "./provider-websocket.js"; diff --git a/src.ts/providers/provider-otterscan.ts b/src.ts/providers/provider-otterscan.ts new file mode 100644 index 0000000000..aa9ff8897b --- /dev/null +++ b/src.ts/providers/provider-otterscan.ts @@ -0,0 +1,321 @@ +/** + * The Otterscan provider extends JsonRpcProvider to provide + * specialized methods for interacting with Erigon nodes that expose + * the ots_* JSON-RPC methods. + * + * These methods are optimized for blockchain explorers and provide + * efficient access to transaction details, internal operations, + * and paginated transaction history. + * + * @_section: api/providers/otterscan:Otterscan Provider [about-otterscanProvider] + */ + +import { Interface } from "../abi/index.js"; +import { dataSlice } from "../utils/index.js"; +import { JsonRpcProvider } from "./provider-jsonrpc.js"; + +import type { JsonRpcApiProviderOptions } from "./provider-jsonrpc.js"; +import type { Networkish } from "./network.js"; +import type { FetchRequest } from "../utils/index.js"; + +export type Hex = `0x${string}`; + +/** + * Internal operation types returned by ots_getInternalOperations + */ +export interface OtsInternalOp { + /** Operation type: 0=transfer, 1=selfdestruct, 2=create, 3=create2 */ + type: 0 | 1 | 2 | 3; + /** Source address */ + from: string; + /** Target address (null for self-destruct operations) */ + to: string | null; + /** Value transferred (hex quantity) */ + value: Hex; +} + +/** + * Block details with issuance and fee information + */ +export interface OtsBlockDetails { + /** Raw block data */ + block: any; + /** Number of transactions in the block */ + transactionCount: number; + /** Block reward information (optional) */ + issuance?: { + blockReward: Hex; + uncleReward: Hex; + issuance: Hex; + }; + /** Total fees collected in the block */ + totalFees?: Hex; +} + +/** + * Paginated block transactions with receipts + */ +export interface OtsBlockTxPage { + /** Transaction bodies with input truncated to 4-byte selector */ + transactions: Array; + /** Receipts with logs and bloom set to null */ + receipts: Array; +} + +/** + * Paginated search results for address transaction history + */ +export interface OtsSearchPage { + /** Array of transactions */ + txs: Array; + /** Array of corresponding receipts */ + receipts: Array; + /** Whether this is the first page */ + firstPage: boolean; + /** Whether this is the last page */ + lastPage: boolean; +} + +/** + * Contract creator information + */ +export interface OtsContractCreator { + /** Transaction hash where contract was created */ + hash: string; + /** Address of the contract creator */ + creator: string; +} + +/** + * The OtterscanProvider extends JsonRpcProvider to add support for + * Erigon's OTS (Otterscan) namespace methods. + * + * These methods provide efficient access to blockchain data optimized + * for explorer applications. + * + * **Note**: OTS methods are only available on Erigon nodes with the + * ots namespace enabled via --http.api "eth,erigon,trace,ots" + */ +export class OtterscanProvider extends JsonRpcProvider { + + constructor(url?: string | FetchRequest, network?: Networkish, options?: JsonRpcApiProviderOptions) { + super(url, network, options); + } + + /** + * Get the OTS API level supported by the node + * @returns The API level number + */ + async otsApiLevel(): Promise { + return await this.send("ots_getApiLevel", []); + } + + /** + * Check if an address has code at a specific block + * @param address - The address to check + * @param blockTag - Block number or "latest" + * @returns True if address has code + */ + async hasCode(address: string, blockTag: string | number | "latest" = "latest"): Promise { + const blockNumber = blockTag === "latest" ? "latest" : Number(blockTag); + return await this.send("ots_hasCode", [address, blockNumber]); + } + + /** + * Get internal operations (transfers, creates, selfdestructs) for a transaction + * @param txHash - Transaction hash + * @returns Array of internal operations + */ + async getInternalOperations(txHash: string): Promise { + return await this.send("ots_getInternalOperations", [txHash]); + } + + /** + * Get raw revert data for a failed transaction + * @param txHash - Transaction hash + * @returns Raw revert data as hex string, "0x" if no error + */ + async getTransactionErrorData(txHash: string): Promise { + return await this.send("ots_getTransactionError", [txHash]); + } + + /** + * Get human-readable revert reason for a failed transaction + * @param txHash - Transaction hash + * @param customAbi - Optional custom ABI for decoding custom errors + * @returns Decoded error message or null if no error + */ + async getTransactionRevertReason(txHash: string, customAbi?: any[]): Promise { + const data: string = await this.getTransactionErrorData(txHash); + if (data === "0x") return null; + + // Try to decode Error(string) - the most common case + const ERROR_SIG = "0x08c379a0"; + if (data.startsWith(ERROR_SIG)) { + try { + const iface = new Interface(["error Error(string)"]); + const decoded = iface.decodeErrorResult("Error", data); + return String(decoded[0]); + } catch { + // Fall through to other attempts + } + } + + // Try custom error set if provided + if (customAbi) { + try { + const iface = new Interface(customAbi); + const parsed = iface.parseError(data); + if (parsed) { + return `${parsed.name}(${parsed.args.map((a) => { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }).join(",")})`; + } + } catch { + // Fall through to selector display + } + } + + // Last resort: show 4-byte selector + return `revert data selector ${dataSlice(data, 0, 4)}`; + } + + /** + * Get execution trace for a transaction + * @param txHash - Transaction hash + * @returns Trace data + */ + async traceTransaction(txHash: string): Promise { + return await this.send("ots_traceTransaction", [txHash]); + } + + /** + * Get detailed block information including issuance and fees + * @param blockNumber - Block number + * @returns Block details with additional metadata + */ + async getBlockDetails(blockNumber: number): Promise { + return await this.send("ots_getBlockDetails", [blockNumber]); + } + + /** + * Get paginated transactions for a block + * @param blockNumber - Block number + * @param page - Page number (0-based) + * @param pageSize - Number of transactions per page + * @returns Page of transactions and receipts + */ + async getBlockTransactions(blockNumber: number, page: number, pageSize: number): Promise { + return await this.send("ots_getBlockTransactions", [blockNumber, page, pageSize]); + } + + /** + * Search for transactions before a specific block for an address + * @param address - Address to search for + * @param blockNumber - Starting block number + * @param pageSize - Maximum results to return + * @returns Page of transactions + */ + async searchTransactionsBefore(address: string, blockNumber: number, pageSize: number): Promise { + return await this.send("ots_searchTransactionsBefore", [address, blockNumber, pageSize]); + } + + /** + * Search for transactions after a specific block for an address + * @param address - Address to search for + * @param blockNumber - Starting block number + * @param pageSize - Maximum results to return + * @returns Page of transactions + */ + async searchTransactionsAfter(address: string, blockNumber: number, pageSize: number): Promise { + return await this.send("ots_searchTransactionsAfter", [address, blockNumber, pageSize]); + } + + /** + * Get transaction hash by sender address and nonce + * @param sender - Sender address + * @param nonce - Transaction nonce + * @returns Transaction hash or null if not found + */ + async getTransactionBySenderAndNonce(sender: string, nonce: number): Promise { + return await this.send("ots_getTransactionBySenderAndNonce", [sender, nonce]); + } + + /** + * Get contract creator information + * @param address - Contract address + * @returns Creator info or null if not a contract + */ + async getContractCreator(address: string): Promise { + return await this.send("ots_getContractCreator", [address]); + } + + /** + * Verify OTS availability and check minimum API level + * @param minLevel - Minimum required API level (default: 0) + * @throws Error if OTS is unavailable or API level too low + */ + async ensureOts(minLevel: number = 0): Promise { + try { + const level = await this.otsApiLevel(); + if (level < minLevel) { + throw new Error(`ots_getApiLevel ${level} < required ${minLevel}`); + } + } catch (error: any) { + const err = new Error(`Erigon OTS namespace unavailable or too old: ${error?.message ?? error}`); + (err as any).code = "OTS_UNAVAILABLE"; + throw err; + } + } + + /** + * Iterate through transaction history for an address + * @param address - Address to search + * @param direction - Search direction ("before" or "after") + * @param startBlock - Starting block number + * @param pageSize - Results per page (default: 500) + * @yields Object with tx and receipt for each transaction + */ + async *iterateAddressHistory( + address: string, + direction: "before" | "after", + startBlock: number, + pageSize: number = 500 + ): AsyncGenerator<{ tx: any; receipt: any }, void, unknown> { + let currentBlock = startBlock; + + while (true) { + const page = direction === "before" + ? await this.searchTransactionsBefore(address, currentBlock, pageSize) + : await this.searchTransactionsAfter(address, currentBlock, pageSize); + + // Yield each transaction with its receipt + for (let i = 0; i < page.txs.length; i++) { + yield { + tx: page.txs[i], + receipt: page.receipts[i] + }; + } + + // Check if we've reached the end + if (page.lastPage) break; + + // Update block cursor for next iteration + const lastTx = page.txs[page.txs.length - 1]; + if (!lastTx) break; + + const nextBlock = Number(lastTx.blockNumber); + + // Prevent infinite loops from malformed API responses + if (nextBlock === currentBlock) { + throw new Error(`Iterator stuck on block ${currentBlock}. API returned same block number.`); + } + + currentBlock = nextBlock; + } + } +} \ No newline at end of file From d0ba30470bb765e1833dc0eb635b9c3c102539f4 Mon Sep 17 00:00:00 2001 From: Kyle Thornton Date: Fri, 22 Aug 2025 13:30:25 -0700 Subject: [PATCH 2/5] Getting more specific with the return types. --- src.ts/_tests/test-providers-otterscan.ts | 15 ++- src.ts/ethers.ts | 4 +- src.ts/providers/index.ts | 14 +- src.ts/providers/provider-otterscan.ts | 149 +++++++++++++++++----- 4 files changed, 132 insertions(+), 50 deletions(-) diff --git a/src.ts/_tests/test-providers-otterscan.ts b/src.ts/_tests/test-providers-otterscan.ts index 423776ebfe..1e976ea1fe 100644 --- a/src.ts/_tests/test-providers-otterscan.ts +++ b/src.ts/_tests/test-providers-otterscan.ts @@ -4,13 +4,16 @@ import { FetchRequest, OtterscanProvider, JsonRpcProvider, - type OtsInternalOp, - type OtsBlockDetails, - type OtsBlockTxPage, - type OtsSearchPage, - type OtsContractCreator, } from "../index.js"; +import type { + OtsInternalOp, + OtsBlockDetails, + OtsBlockTxPage, + OtsSearchResult, + OtsContractCreator, +} from "../providers/provider-otterscan.js"; + describe("Test Otterscan Provider", function() { // Mock OTS responses for testing function createMockOtsProvider() { @@ -258,7 +261,7 @@ describe("Test Otterscan Provider", function() { const internalOps: OtsInternalOp[] = await provider.getInternalOperations("0x123"); const blockDetails: OtsBlockDetails = await provider.getBlockDetails(4096); const blockTxs: OtsBlockTxPage = await provider.getBlockTransactions(4096, 0, 10); - const searchResults: OtsSearchPage = await provider.searchTransactionsBefore("0x123", 4096, 10); + const searchResults: OtsSearchResult = await provider.searchTransactionsBefore("0x123", 4096, 10); const creator: OtsContractCreator | null = await provider.getContractCreator("0x123"); // Basic type assertions diff --git a/src.ts/ethers.ts b/src.ts/ethers.ts index 843cf6e05c..4c0968f31e 100644 --- a/src.ts/ethers.ts +++ b/src.ts/ethers.ts @@ -176,9 +176,11 @@ export type { Eip6963ProviderInfo, EventFilter, Filter, FilterByBlockHash, GasCostParameters, JsonRpcApiProviderOptions, JsonRpcError, JsonRpcPayload, JsonRpcResult, JsonRpcTransactionRequest, - Hex, OtsInternalOp, OtsBlockDetails, OtsBlockTxPage, OtsSearchPage, OtsContractCreator, LogParams, MinedBlock, MinedTransactionResponse, Networkish, OrphanFilter, + OtsLog, OtsTransaction, OtsReceipt, OtsSearchResult, + OtsInternalOp, OtsBlockDetails, OtsBlockTxPage, OtsSearchPage, + OtsContractCreator, PerformActionFilter, PerformActionRequest, PerformActionTransaction, PreparedTransactionRequest, ProviderEvent, Subscriber, Subscription, TopicFilter, TransactionReceiptParams, TransactionRequest, diff --git a/src.ts/providers/index.ts b/src.ts/providers/index.ts index 6a4be059c0..84450e4eb4 100644 --- a/src.ts/providers/index.ts +++ b/src.ts/providers/index.ts @@ -59,6 +59,12 @@ export { JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner } from "./provider-j export { OtterscanProvider } from "./provider-otterscan.js"; +export type { + OtsLog, OtsTransaction, OtsReceipt, OtsSearchResult, + OtsInternalOp, OtsBlockDetails, OtsBlockTxPage, OtsSearchPage, + OtsContractCreator +} from "./provider-otterscan.js"; + export { BrowserProvider } from "./provider-browser.js"; export { AlchemyProvider } from "./provider-alchemy.js"; @@ -129,14 +135,6 @@ export type { JsonRpcTransactionRequest, } from "./provider-jsonrpc.js"; -export type { - Hex, - OtsInternalOp, - OtsBlockDetails, - OtsBlockTxPage, - OtsSearchPage, - OtsContractCreator -} from "./provider-otterscan.js"; export type { WebSocketCreator, WebSocketLike diff --git a/src.ts/providers/provider-otterscan.ts b/src.ts/providers/provider-otterscan.ts index aa9ff8897b..5d2996a706 100644 --- a/src.ts/providers/provider-otterscan.ts +++ b/src.ts/providers/provider-otterscan.ts @@ -7,7 +7,7 @@ * efficient access to transaction details, internal operations, * and paginated transaction history. * - * @_section: api/providers/otterscan:Otterscan Provider [about-otterscanProvider] + * @_subsection: api/providers/thirdparty:Otterscan [providers-otterscan] */ import { Interface } from "../abi/index.js"; @@ -17,8 +17,84 @@ import { JsonRpcProvider } from "./provider-jsonrpc.js"; import type { JsonRpcApiProviderOptions } from "./provider-jsonrpc.js"; import type { Networkish } from "./network.js"; import type { FetchRequest } from "../utils/index.js"; +import type { BlockParams } from "./formatting.js"; +import type { Fragment } from "../abi/index.js"; +import type { AccessList } from "../transaction/index.js"; +import type { HexString } from "../utils/data.js"; -export type Hex = `0x${string}`; +// Log entry in transaction receipts - matches ethers LogParams but with hex strings from raw RPC +export interface OtsLog { + address: string; + topics: ReadonlyArray; + data: string; + blockNumber: string; // hex string from RPC, not parsed number + transactionHash: string; + transactionIndex: string; // hex string from RPC, not parsed number + blockHash: string; + logIndex: string; // hex string from RPC, not parsed number + removed: boolean; +} + +// Otterscan transaction type - raw RPC response with hex-encoded values +export interface OtsTransaction { + // Core transaction fields (always present) + blockHash: string; + blockNumber: string; // hex-encoded number + from: string; + gas: string; // hex-encoded gasLimit + gasPrice: string; // hex-encoded bigint + hash: string; + input: string; // transaction data + nonce: string; // hex-encoded number + to: string; + transactionIndex: string; // hex-encoded number + value: string; // hex-encoded bigint + type: string; // hex-encoded transaction type (0x0, 0x1, 0x2, etc.) + chainId: string; // hex-encoded bigint + + // Signature components (always present) + v: string; // hex-encoded + r: string; // hex signature component + s: string; // hex signature component + + // EIP-1559 fields (present in type 0x2 transactions) + maxPriorityFeePerGas?: string; // hex-encoded bigint + maxFeePerGas?: string; // hex-encoded bigint + yParity?: string; // hex-encoded (0x0 or 0x1) + + // EIP-2930/EIP-1559 field + accessList?: AccessList; +} + +// Otterscan receipt type - raw RPC response format +export interface OtsReceipt { + // Core receipt fields + blockHash: string; + blockNumber: string; // hex-encoded number + contractAddress: string | null; // null for non-contract-creating txs + cumulativeGasUsed: string; // hex-encoded bigint + effectiveGasPrice: string; // hex-encoded bigint + from: string; + gasUsed: string; // hex-encoded bigint + logs: OtsLog[]; + logsBloom: string; // hex-encoded bloom filter + status: string; // hex-encoded: "0x1" success, "0x0" failure + to: string; + transactionHash: string; + transactionIndex: string; // hex-encoded number + type: string; // hex-encoded transaction type + + // Otterscan-specific extension + timestamp: number; // Unix timestamp as actual number (not hex) +} + +// Otterscan search page result +export interface OtsSearchResult { + txs: OtsTransaction[]; + receipts: OtsReceipt[]; + firstPage: boolean; + lastPage: boolean; +} /** * Internal operation types returned by ots_getInternalOperations @@ -31,7 +107,7 @@ export interface OtsInternalOp { /** Target address (null for self-destruct operations) */ to: string | null; /** Value transferred (hex quantity) */ - value: Hex; + value: HexString; } /** @@ -39,17 +115,17 @@ export interface OtsInternalOp { */ export interface OtsBlockDetails { /** Raw block data */ - block: any; + block: BlockParams; /** Number of transactions in the block */ transactionCount: number; /** Block reward information (optional) */ issuance?: { - blockReward: Hex; - uncleReward: Hex; - issuance: Hex; + blockReward: HexString; + uncleReward: HexString; + issuance: HexString; }; /** Total fees collected in the block */ - totalFees?: Hex; + totalFees?: HexString; } /** @@ -57,9 +133,9 @@ export interface OtsBlockDetails { */ export interface OtsBlockTxPage { /** Transaction bodies with input truncated to 4-byte selector */ - transactions: Array; + transactions: Array; /** Receipts with logs and bloom set to null */ - receipts: Array; + receipts: Array; } /** @@ -67,9 +143,9 @@ export interface OtsBlockTxPage { */ export interface OtsSearchPage { /** Array of transactions */ - txs: Array; + txs: OtsTransaction[]; /** Array of corresponding receipts */ - receipts: Array; + receipts: OtsReceipt[]; /** Whether this is the first page */ firstPage: boolean; /** Whether this is the last page */ @@ -89,15 +165,14 @@ export interface OtsContractCreator { /** * The OtterscanProvider extends JsonRpcProvider to add support for * Erigon's OTS (Otterscan) namespace methods. - * + * * These methods provide efficient access to blockchain data optimized * for explorer applications. - * + * * **Note**: OTS methods are only available on Erigon nodes with the * ots namespace enabled via --http.api "eth,erigon,trace,ots" */ export class OtterscanProvider extends JsonRpcProvider { - constructor(url?: string | FetchRequest, network?: Networkish, options?: JsonRpcApiProviderOptions) { super(url, network, options); } @@ -135,7 +210,7 @@ export class OtterscanProvider extends JsonRpcProvider { * @param txHash - Transaction hash * @returns Raw revert data as hex string, "0x" if no error */ - async getTransactionErrorData(txHash: string): Promise { + async getTransactionErrorData(txHash: string): Promise { return await this.send("ots_getTransactionError", [txHash]); } @@ -145,7 +220,7 @@ export class OtterscanProvider extends JsonRpcProvider { * @param customAbi - Optional custom ABI for decoding custom errors * @returns Decoded error message or null if no error */ - async getTransactionRevertReason(txHash: string, customAbi?: any[]): Promise { + async getTransactionRevertReason(txHash: string, customAbi?: Fragment[]): Promise { const data: string = await this.getTransactionErrorData(txHash); if (data === "0x") return null; @@ -167,13 +242,15 @@ export class OtterscanProvider extends JsonRpcProvider { const iface = new Interface(customAbi); const parsed = iface.parseError(data); if (parsed) { - return `${parsed.name}(${parsed.args.map((a) => { - try { - return JSON.stringify(a); - } catch { - return String(a); - } - }).join(",")})`; + return `${parsed.name}(${parsed.args + .map(a => { + try { + return JSON.stringify(a); + } catch { + return String(a); + } + }) + .join(",")})`; } } catch { // Fall through to selector display @@ -220,7 +297,7 @@ export class OtterscanProvider extends JsonRpcProvider { * @param pageSize - Maximum results to return * @returns Page of transactions */ - async searchTransactionsBefore(address: string, blockNumber: number, pageSize: number): Promise { + async searchTransactionsBefore(address: string, blockNumber: number, pageSize: number): Promise { return await this.send("ots_searchTransactionsBefore", [address, blockNumber, pageSize]); } @@ -231,7 +308,7 @@ export class OtterscanProvider extends JsonRpcProvider { * @param pageSize - Maximum results to return * @returns Page of transactions */ - async searchTransactionsAfter(address: string, blockNumber: number, pageSize: number): Promise { + async searchTransactionsAfter(address: string, blockNumber: number, pageSize: number): Promise { return await this.send("ots_searchTransactionsAfter", [address, blockNumber, pageSize]); } @@ -265,8 +342,9 @@ export class OtterscanProvider extends JsonRpcProvider { if (level < minLevel) { throw new Error(`ots_getApiLevel ${level} < required ${minLevel}`); } - } catch (error: any) { - const err = new Error(`Erigon OTS namespace unavailable or too old: ${error?.message ?? error}`); + } catch (error: unknown) { + const errorMsg = error instanceof Error ? error.message : String(error); + const err = new Error(`Erigon OTS namespace unavailable or too old: ${errorMsg}`); (err as any).code = "OTS_UNAVAILABLE"; throw err; } @@ -285,13 +363,14 @@ export class OtterscanProvider extends JsonRpcProvider { direction: "before" | "after", startBlock: number, pageSize: number = 500 - ): AsyncGenerator<{ tx: any; receipt: any }, void, unknown> { + ): AsyncGenerator<{ tx: OtsTransaction; receipt: OtsReceipt }, void, unknown> { let currentBlock = startBlock; while (true) { - const page = direction === "before" - ? await this.searchTransactionsBefore(address, currentBlock, pageSize) - : await this.searchTransactionsAfter(address, currentBlock, pageSize); + const page = + direction === "before" + ? await this.searchTransactionsBefore(address, currentBlock, pageSize) + : await this.searchTransactionsAfter(address, currentBlock, pageSize); // Yield each transaction with its receipt for (let i = 0; i < page.txs.length; i++) { @@ -309,13 +388,13 @@ export class OtterscanProvider extends JsonRpcProvider { if (!lastTx) break; const nextBlock = Number(lastTx.blockNumber); - + // Prevent infinite loops from malformed API responses if (nextBlock === currentBlock) { throw new Error(`Iterator stuck on block ${currentBlock}. API returned same block number.`); } - + currentBlock = nextBlock; } } -} \ No newline at end of file +} From 26617c64f8b75dd96c14092bacd8edd4d3252ad6 Mon Sep 17 00:00:00 2001 From: Kyle Thornton Date: Fri, 22 Aug 2025 17:19:24 -0700 Subject: [PATCH 3/5] Using standard ethers.js types where Otterscan is extending existing response types. Adding missing getBlockDetailsByHash() method. Added real ots_ raw data to the unit tests to make them more accurate. Added the "reasoning" from the otterscan docs to the JSDoc comments. --- src.ts/_tests/test-providers-otterscan.ts | 209 +++++++++++++-------- src.ts/ethers.ts | 6 +- src.ts/providers/index.ts | 6 +- src.ts/providers/provider-otterscan.ts | 218 +++++++++++----------- 4 files changed, 247 insertions(+), 192 deletions(-) diff --git a/src.ts/_tests/test-providers-otterscan.ts b/src.ts/_tests/test-providers-otterscan.ts index 1e976ea1fe..d74eafb444 100644 --- a/src.ts/_tests/test-providers-otterscan.ts +++ b/src.ts/_tests/test-providers-otterscan.ts @@ -1,30 +1,27 @@ import assert from "assert"; -import { - FetchRequest, - OtterscanProvider, - JsonRpcProvider, -} from "../index.js"; +import { FetchRequest, OtterscanProvider, JsonRpcProvider } from "../index.js"; import type { OtsInternalOp, OtsBlockDetails, - OtsBlockTxPage, - OtsSearchResult, - OtsContractCreator, + OtsBlockTransactionsPage, + OtsAddressTransactionsPage, + OtsContractCreator } from "../providers/provider-otterscan.js"; -describe("Test Otterscan Provider", function() { +describe("Test Otterscan Provider", function () { // Mock OTS responses for testing function createMockOtsProvider() { const req = new FetchRequest("http://localhost:8545/"); - + req.getUrlFunc = async (_req, signal) => { - const bodyStr = typeof _req.body === "string" ? _req.body : new TextDecoder().decode(_req.body || new Uint8Array()); + const bodyStr = + typeof _req.body === "string" ? _req.body : new TextDecoder().decode(_req.body || new Uint8Array()); const request = JSON.parse(bodyStr || "{}"); - + let result: any; - + switch (request.method) { case "ots_getApiLevel": result = 8; @@ -34,12 +31,14 @@ describe("Test Otterscan Provider", function() { result = request.params[0] !== "0x0000000000000000000000000000000000000000"; break; case "ots_getInternalOperations": - result = [{ - type: 0, - from: "0x1234567890123456789012345678901234567890", - to: "0x0987654321098765432109876543210987654321", - value: "0x1000000000000000000" - }]; + result = [ + { + type: 0, + from: "0x1234567890123456789012345678901234567890", + to: "0x0987654321098765432109876543210987654321", + value: "0x1000000000000000000" + } + ]; break; case "ots_getTransactionError": result = "0x"; @@ -51,36 +50,86 @@ describe("Test Otterscan Provider", function() { result = { block: { hash: "0x123abc", - number: "0x1000" + number: "0x1000", + timestamp: "0x499602d2", + parentHash: "0x000abc", + nonce: "0x0", + difficulty: "0x0", + gasLimit: "0x1c9c380", + gasUsed: "0x5208", + miner: "0x0000000000000000000000000000000000000000", + extraData: "0x", + baseFeePerGas: "0x0", + logsBloom: null }, - transactionCount: 5, totalFees: "0x5000000000000000" }; break; case "ots_getBlockTransactions": result = { - transactions: [{ - hash: "0x456def", - from: "0x1111111111111111111111111111111111111111", - to: "0x2222222222222222222222222222222222222222", - value: "0x1000000000000000000" - }], - receipts: [{ - status: "0x1", - gasUsed: "0x5208" - }] + transactions: [ + { + hash: "0x456def", + from: "0x1111111111111111111111111111111111111111", + to: "0x2222222222222222222222222222222222222222", + value: "0x1000000000000000000" + } + ], + receipts: [ + { + status: "0x1", + gasUsed: "0x5208" + } + ] }; break; case "ots_searchTransactionsBefore": case "ots_searchTransactionsAfter": result = { - txs: [{ - hash: "0x789ghi", - blockNumber: "0x1000" - }], - receipts: [{ - status: "0x1" - }], + txs: [ + { + blockHash: "0x5adc8e4d5d8eee95a3b390e9cbed9f1633f94dae073b70f2c890d419b7bb7ca0", + blockNumber: "0x121e890", + from: "0x17076a2bdff9db26544a9201faad098b76b51b31", + gas: "0x5208", + gasPrice: "0x44721f210", + maxPriorityFeePerGas: "0x5f5e100", + maxFeePerGas: "0x5e2b132fb", + hash: "0xe689a340d805b0e6d5f26a4498caecec81752003b9352bb5802819641baaf9a9", + input: "0x", + nonce: "0x1f", + to: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + transactionIndex: "0x11", + value: "0x38d7ea4c68000", + type: "0x2", + accessList: [], + chainId: "0x1", + v: "0x0", + yParity: "0x0", + r: "0xd3ecccab74bc708d2ead0d913c38990870c31f3f3eee3c7354752c1fdd826b19", + s: "0x275c7656704d35da38b197d3365ef73388d42a8ca2304ce849547ce2348b087" + } + ], + receipts: [ + { + blockHash: "0x5adc8e4d5d8eee95a3b390e9cbed9f1633f94dae073b70f2c890d419b7bb7ca0", + blockNumber: "0x121e890", + contractAddress: null, + cumulativeGasUsed: "0x2654c4", + effectiveGasPrice: "0x44721f210", + from: "0x17076a2bdff9db26544a9201faad098b76b51b31", + gasUsed: "0x5208", + logs: [], + logsBloom: + "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + status: "0x1", + timestamp: 1705166675, + to: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + transactionHash: "0xe689a340d805b0e6d5f26a4498caecec81752003b9352bb5802819641baaf9a9", + transactionIndex: "0x11", + type: "0x2" + } + ], firstPage: true, lastPage: false }; @@ -103,13 +152,13 @@ describe("Test Otterscan Provider", function() { default: throw new Error(`Unsupported method: ${request.method}`); } - + const response = { id: request.id, jsonrpc: "2.0", result }; - + return { statusCode: 200, statusMessage: "OK", @@ -117,39 +166,39 @@ describe("Test Otterscan Provider", function() { body: new TextEncoder().encode(JSON.stringify(response)) }; }; - + return new OtterscanProvider(req, 1, { staticNetwork: true }); } - it("should extend JsonRpcProvider", function() { + it("should extend JsonRpcProvider", function () { const provider = createMockOtsProvider(); assert(provider instanceof OtterscanProvider, "should be OtterscanProvider instance"); assert(provider instanceof JsonRpcProvider, "should extend JsonRpcProvider"); }); - it("should get OTS API level", async function() { + it("should get OTS API level", async function () { const provider = createMockOtsProvider(); const apiLevel = await provider.otsApiLevel(); assert.strictEqual(apiLevel, 8, "should return API level 8"); }); - it("should check if address has code", async function() { + it("should check if address has code", async function () { const provider = createMockOtsProvider(); - + const hasCodeTrue = await provider.hasCode("0x1234567890123456789012345678901234567890"); assert.strictEqual(hasCodeTrue, true, "should return true for non-zero address"); - + const hasCodeFalse = await provider.hasCode("0x0000000000000000000000000000000000000000"); assert.strictEqual(hasCodeFalse, false, "should return false for zero address"); }); - it("should get internal operations", async function() { + it("should get internal operations", async function () { const provider = createMockOtsProvider(); const internalOps = await provider.getInternalOperations("0x123"); - + assert(Array.isArray(internalOps), "should return array"); assert.strictEqual(internalOps.length, 1, "should have one operation"); - + const op = internalOps[0]; assert.strictEqual(op.type, 0, "should have type 0"); assert.strictEqual(op.from, "0x1234567890123456789012345678901234567890", "should have correct from"); @@ -157,84 +206,92 @@ describe("Test Otterscan Provider", function() { assert.strictEqual(op.value, "0x1000000000000000000", "should have correct value"); }); - it("should get transaction error data", async function() { + it("should get transaction error data", async function () { const provider = createMockOtsProvider(); const errorData = await provider.getTransactionErrorData("0x123"); assert.strictEqual(errorData, "0x", "should return empty error data"); }); - it("should get transaction revert reason", async function() { + it("should get transaction revert reason", async function () { const provider = createMockOtsProvider(); const revertReason = await provider.getTransactionRevertReason("0x123"); assert.strictEqual(revertReason, null, "should return null for no error"); }); - it("should trace transaction", async function() { + it("should trace transaction", async function () { const provider = createMockOtsProvider(); const trace = await provider.traceTransaction("0x123"); assert(typeof trace === "object", "should return trace object"); assert(Array.isArray(trace.calls), "should have calls array"); }); - it("should get block details", async function() { + it("should get block details", async function () { const provider = createMockOtsProvider(); const blockDetails = await provider.getBlockDetails(4096); - + assert(typeof blockDetails === "object", "should return object"); - assert.strictEqual(blockDetails.transactionCount, 5, "should have transaction count"); + assert.strictEqual( + blockDetails.block.logsBloom, + null, + "should have null logsBloom (removed for efficiency)" + ); assert.strictEqual(blockDetails.totalFees, "0x5000000000000000", "should have total fees"); assert(blockDetails.block, "should have block data"); }); - it("should get block transactions", async function() { + it("should get block transactions", async function () { const provider = createMockOtsProvider(); const blockTxs = await provider.getBlockTransactions(4096, 0, 10); - + assert(Array.isArray(blockTxs.transactions), "should have transactions array"); assert(Array.isArray(blockTxs.receipts), "should have receipts array"); assert.strictEqual(blockTxs.transactions.length, 1, "should have one transaction"); assert.strictEqual(blockTxs.receipts.length, 1, "should have one receipt"); }); - it("should search transactions before", async function() { + it("should search transactions before", async function () { const provider = createMockOtsProvider(); const searchResults = await provider.searchTransactionsBefore("0x123", 4096, 10); - + assert(Array.isArray(searchResults.txs), "should have txs array"); assert(Array.isArray(searchResults.receipts), "should have receipts array"); assert.strictEqual(searchResults.firstPage, true, "should be first page"); assert.strictEqual(searchResults.lastPage, false, "should not be last page"); }); - it("should search transactions after", async function() { + it("should search transactions after", async function () { const provider = createMockOtsProvider(); const searchResults = await provider.searchTransactionsAfter("0x123", 4096, 10); - + assert(Array.isArray(searchResults.txs), "should have txs array"); assert(Array.isArray(searchResults.receipts), "should have receipts array"); }); - it("should get transaction by sender and nonce", async function() { + it("should get transaction by sender and nonce", async function () { const provider = createMockOtsProvider(); const txHash = await provider.getTransactionBySenderAndNonce("0x123", 0); assert.strictEqual(txHash, "0xabcdef123456789", "should return transaction hash"); }); - it("should get contract creator", async function() { + it("should get contract creator", async function () { const provider = createMockOtsProvider(); const creator = await provider.getContractCreator("0x123"); - + assert(typeof creator === "object", "should return object"); assert.strictEqual(creator?.hash, "0x987654321", "should have creation hash"); - assert.strictEqual(creator?.creator, "0x1111111111111111111111111111111111111111", "should have creator address"); + assert.strictEqual( + creator?.creator, + "0x1111111111111111111111111111111111111111", + "should have creator address" + ); }); - it("should ensure OTS capability", async function() { + it("should ensure OTS capability", async function () { const provider = createMockOtsProvider(); - + // Should not throw await provider.ensureOts(8); - + // Should throw for higher requirement try { await provider.ensureOts(10); @@ -245,25 +302,25 @@ describe("Test Otterscan Provider", function() { } }); - it("should have async iterator for address history", function() { + it("should have async iterator for address history", function () { const provider = createMockOtsProvider(); const iterator = provider.iterateAddressHistory("0x123", "before", 4096); - + assert(typeof iterator[Symbol.asyncIterator] === "function", "should be async iterable"); }); - it("should properly type return values", async function() { + it("should properly type return values", async function () { const provider = createMockOtsProvider(); - + // Test TypeScript typing works correctly const apiLevel: number = await provider.otsApiLevel(); const hasCode: boolean = await provider.hasCode("0x123"); const internalOps: OtsInternalOp[] = await provider.getInternalOperations("0x123"); const blockDetails: OtsBlockDetails = await provider.getBlockDetails(4096); - const blockTxs: OtsBlockTxPage = await provider.getBlockTransactions(4096, 0, 10); - const searchResults: OtsSearchResult = await provider.searchTransactionsBefore("0x123", 4096, 10); + const blockTxs: OtsBlockTransactionsPage = await provider.getBlockTransactions(4096, 0, 10); + const searchResults: OtsAddressTransactionsPage = await provider.searchTransactionsBefore("0x123", 4096, 10); const creator: OtsContractCreator | null = await provider.getContractCreator("0x123"); - + // Basic type assertions assert.strictEqual(typeof apiLevel, "number"); assert.strictEqual(typeof hasCode, "boolean"); @@ -273,4 +330,4 @@ describe("Test Otterscan Provider", function() { assert(typeof searchResults === "object" && Array.isArray(searchResults.txs)); assert(creator === null || typeof creator === "object"); }); -}); \ No newline at end of file +}); diff --git a/src.ts/ethers.ts b/src.ts/ethers.ts index 4c0968f31e..ef1c350d71 100644 --- a/src.ts/ethers.ts +++ b/src.ts/ethers.ts @@ -178,9 +178,9 @@ export type { JsonRpcPayload, JsonRpcResult, JsonRpcTransactionRequest, LogParams, MinedBlock, MinedTransactionResponse, Networkish, OrphanFilter, - OtsLog, OtsTransaction, OtsReceipt, OtsSearchResult, - OtsInternalOp, OtsBlockDetails, OtsBlockTxPage, OtsSearchPage, - OtsContractCreator, + OtsTransactionReceiptParams, OtsBlockTransactionReceipt, + OtsBlockParams, OtsInternalOp, OtsBlockDetails, OtsBlockTransactionsPage, + OtsAddressTransactionsPage, OtsContractCreator, PerformActionFilter, PerformActionRequest, PerformActionTransaction, PreparedTransactionRequest, ProviderEvent, Subscriber, Subscription, TopicFilter, TransactionReceiptParams, TransactionRequest, diff --git a/src.ts/providers/index.ts b/src.ts/providers/index.ts index 84450e4eb4..ac66c227e9 100644 --- a/src.ts/providers/index.ts +++ b/src.ts/providers/index.ts @@ -60,9 +60,9 @@ export { JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner } from "./provider-j export { OtterscanProvider } from "./provider-otterscan.js"; export type { - OtsLog, OtsTransaction, OtsReceipt, OtsSearchResult, - OtsInternalOp, OtsBlockDetails, OtsBlockTxPage, OtsSearchPage, - OtsContractCreator + OtsTransactionReceiptParams, OtsBlockTransactionReceipt, + OtsBlockParams, OtsInternalOp, OtsBlockDetails, OtsBlockTransactionsPage, + OtsAddressTransactionsPage, OtsContractCreator } from "./provider-otterscan.js"; export { BrowserProvider } from "./provider-browser.js"; diff --git a/src.ts/providers/provider-otterscan.ts b/src.ts/providers/provider-otterscan.ts index 5d2996a706..8368b7b49d 100644 --- a/src.ts/providers/provider-otterscan.ts +++ b/src.ts/providers/provider-otterscan.ts @@ -13,87 +13,17 @@ import { Interface } from "../abi/index.js"; import { dataSlice } from "../utils/index.js"; import { JsonRpcProvider } from "./provider-jsonrpc.js"; +import { formatTransactionReceipt, formatTransactionResponse } from "./format.js"; import type { JsonRpcApiProviderOptions } from "./provider-jsonrpc.js"; import type { Networkish } from "./network.js"; import type { FetchRequest } from "../utils/index.js"; -import type { BlockParams } from "./formatting.js"; +import type { BlockParams, TransactionReceiptParams, TransactionResponseParams } from "./formatting.js"; import type { Fragment } from "../abi/index.js"; -import type { AccessList } from "../transaction/index.js"; -import type { HexString } from "../utils/data.js"; - -// Log entry in transaction receipts - matches ethers LogParams but with hex strings from raw RPC -export interface OtsLog { - address: string; - topics: ReadonlyArray; - data: string; - blockNumber: string; // hex string from RPC, not parsed number - transactionHash: string; - transactionIndex: string; // hex string from RPC, not parsed number - blockHash: string; - logIndex: string; // hex string from RPC, not parsed number - removed: boolean; -} - -// Otterscan transaction type - raw RPC response with hex-encoded values -export interface OtsTransaction { - // Core transaction fields (always present) - blockHash: string; - blockNumber: string; // hex-encoded number - from: string; - gas: string; // hex-encoded gasLimit - gasPrice: string; // hex-encoded bigint - hash: string; - input: string; // transaction data - nonce: string; // hex-encoded number - to: string; - transactionIndex: string; // hex-encoded number - value: string; // hex-encoded bigint - type: string; // hex-encoded transaction type (0x0, 0x1, 0x2, etc.) - chainId: string; // hex-encoded bigint - - // Signature components (always present) - v: string; // hex-encoded - r: string; // hex signature component - s: string; // hex signature component - - // EIP-1559 fields (present in type 0x2 transactions) - maxPriorityFeePerGas?: string; // hex-encoded bigint - maxFeePerGas?: string; // hex-encoded bigint - yParity?: string; // hex-encoded (0x0 or 0x1) - - // EIP-2930/EIP-1559 field - accessList?: AccessList; -} - -// Otterscan receipt type - raw RPC response format -export interface OtsReceipt { - // Core receipt fields - blockHash: string; - blockNumber: string; // hex-encoded number - contractAddress: string | null; // null for non-contract-creating txs - cumulativeGasUsed: string; // hex-encoded bigint - effectiveGasPrice: string; // hex-encoded bigint - from: string; - gasUsed: string; // hex-encoded bigint - logs: OtsLog[]; - logsBloom: string; // hex-encoded bloom filter - status: string; // hex-encoded: "0x1" success, "0x0" failure - to: string; - transactionHash: string; - transactionIndex: string; // hex-encoded number - type: string; // hex-encoded transaction type - - // Otterscan-specific extension - timestamp: number; // Unix timestamp as actual number (not hex) -} -// Otterscan search page result -export interface OtsSearchResult { - txs: OtsTransaction[]; - receipts: OtsReceipt[]; - firstPage: boolean; - lastPage: boolean; +// Formatted Otterscan receipt (extends standard receipt with timestamp) +export interface OtsTransactionReceiptParams extends TransactionReceiptParams { + timestamp: number; // Otterscan adds a Unix timestamp } /** @@ -107,45 +37,62 @@ export interface OtsInternalOp { /** Target address (null for self-destruct operations) */ to: string | null; /** Value transferred (hex quantity) */ - value: HexString; + value: string; +} + +/** + * Block data for Otterscan (transactions list and logsBloom removed for efficiency) + */ +export interface OtsBlockParams extends Omit { + /** Logs bloom set to null for bandwidth efficiency */ + logsBloom: null; } /** * Block details with issuance and fee information + * Returns modified block data (log blooms set to null) plus Otterscan extensions */ export interface OtsBlockDetails { - /** Raw block data */ - block: BlockParams; - /** Number of transactions in the block */ - transactionCount: number; - /** Block reward information (optional) */ + /** Block data with transactions list removed and log blooms set to null for efficiency */ + block: OtsBlockParams; + /** Block issuance information */ issuance?: { - blockReward: HexString; - uncleReward: HexString; - issuance: HexString; + blockReward: string; + uncleReward: string; + issuance: string; }; /** Total fees collected in the block */ - totalFees?: HexString; + totalFees?: string; } /** - * Paginated block transactions with receipts + * Receipt for block transactions (logs and logsBloom set to null for efficiency) */ -export interface OtsBlockTxPage { +export interface OtsBlockTransactionReceipt extends Omit { + /** Logs set to null for bandwidth efficiency */ + logs: null; + /** Logs bloom set to null for bandwidth efficiency */ + logsBloom: null; +} + +/** + * Paginated block transactions with receipts (uses optimized receipt format) + */ +export interface OtsBlockTransactionsPage { /** Transaction bodies with input truncated to 4-byte selector */ - transactions: Array; - /** Receipts with logs and bloom set to null */ - receipts: Array; + transactions: Array; + /** Receipts with logs and bloom set to null for bandwidth efficiency */ + receipts: Array; } /** - * Paginated search results for address transaction history + * Paginated search results for address transaction history (uses standard ethers types) */ -export interface OtsSearchPage { +export interface OtsAddressTransactionsPage { /** Array of transactions */ - txs: OtsTransaction[]; - /** Array of corresponding receipts */ - receipts: OtsReceipt[]; + txs: TransactionResponseParams[]; + /** Array of corresponding receipts with timestamps */ + receipts: OtsTransactionReceiptParams[]; /** Whether this is the first page */ firstPage: boolean; /** Whether this is the last page */ @@ -186,10 +133,11 @@ export class OtterscanProvider extends JsonRpcProvider { } /** - * Check if an address has code at a specific block + * Check if an address has deployed code (is a contract vs EOA) + * More efficient than eth_getCode for checking contract vs EOA status * @param address - The address to check * @param blockTag - Block number or "latest" - * @returns True if address has code + * @returns True if address has code (is a contract) */ async hasCode(address: string, blockTag: string | number | "latest" = "latest"): Promise { const blockNumber = blockTag === "latest" ? "latest" : Number(blockTag); @@ -210,7 +158,7 @@ export class OtterscanProvider extends JsonRpcProvider { * @param txHash - Transaction hash * @returns Raw revert data as hex string, "0x" if no error */ - async getTransactionErrorData(txHash: string): Promise { + async getTransactionErrorData(txHash: string): Promise { return await this.send("ots_getTransactionError", [txHash]); } @@ -272,6 +220,7 @@ export class OtterscanProvider extends JsonRpcProvider { /** * Get detailed block information including issuance and fees + * Tailor-made version of eth_getBlock - removes transaction list and log blooms for efficiency * @param blockNumber - Block number * @returns Block details with additional metadata */ @@ -279,41 +228,89 @@ export class OtterscanProvider extends JsonRpcProvider { return await this.send("ots_getBlockDetails", [blockNumber]); } + /** + * Get detailed block information including issuance and fees (by hash) + * Same as ots_getBlockDetails, but accepts a block hash as parameter + * @param blockHash - Block hash + * @returns Block details with additional metadata + */ + async getBlockDetailsByHash(blockHash: string): Promise { + return await this.send("ots_getBlockDetailsByHash", [blockHash]); + } + /** * Get paginated transactions for a block + * Removes verbose fields like logs from receipts to save bandwidth * @param blockNumber - Block number * @param page - Page number (0-based) * @param pageSize - Number of transactions per page - * @returns Page of transactions and receipts + * @returns Page of transactions and receipts (with logs removed) */ - async getBlockTransactions(blockNumber: number, page: number, pageSize: number): Promise { + async getBlockTransactions(blockNumber: number, page: number, pageSize: number): Promise { return await this.send("ots_getBlockTransactions", [blockNumber, page, pageSize]); } /** - * Search for transactions before a specific block for an address + * Search for inbound/outbound transactions before a specific block for an address + * Provides paginated transaction history with in-node search (no external indexer needed) * @param address - Address to search for * @param blockNumber - Starting block number * @param pageSize - Maximum results to return - * @returns Page of transactions + * @returns Page of transactions and receipts */ - async searchTransactionsBefore(address: string, blockNumber: number, pageSize: number): Promise { - return await this.send("ots_searchTransactionsBefore", [address, blockNumber, pageSize]); + async searchTransactionsBefore( + address: string, + blockNumber: number, + pageSize: number + ): Promise { + const result = (await this.send("ots_searchTransactionsBefore", [address, blockNumber, pageSize])) as { + txs: any[]; + receipts: any[]; + firstPage: boolean; + lastPage: boolean; + }; + return { + ...result, + txs: result.txs.map((tx: any) => formatTransactionResponse(tx)), + receipts: result.receipts.map((receipt: any) => ({ + ...formatTransactionReceipt(receipt), + timestamp: receipt.timestamp + })) + }; } /** - * Search for transactions after a specific block for an address + * Search for inbound/outbound transactions after a specific block for an address + * Provides paginated transaction history with in-node search (no external indexer needed) * @param address - Address to search for * @param blockNumber - Starting block number * @param pageSize - Maximum results to return - * @returns Page of transactions + * @returns Page of transactions and receipts */ - async searchTransactionsAfter(address: string, blockNumber: number, pageSize: number): Promise { - return await this.send("ots_searchTransactionsAfter", [address, blockNumber, pageSize]); + async searchTransactionsAfter( + address: string, + blockNumber: number, + pageSize: number + ): Promise { + const result = (await this.send("ots_searchTransactionsAfter", [address, blockNumber, pageSize])) as { + txs: any[]; + receipts: any[]; + firstPage: boolean; + lastPage: boolean; + }; + return { + ...result, + txs: result.txs.map((tx: any) => formatTransactionResponse(tx)), + receipts: result.receipts.map((receipt: any) => ({ + ...formatTransactionReceipt(receipt), + timestamp: receipt.timestamp + })) + }; } /** * Get transaction hash by sender address and nonce + * Enables navigation between nonces from the same sender (not available in standard JSON-RPC) * @param sender - Sender address * @param nonce - Transaction nonce * @returns Transaction hash or null if not found @@ -323,7 +320,8 @@ export class OtterscanProvider extends JsonRpcProvider { } /** - * Get contract creator information + * Get contract creator information (transaction hash and creator address) + * Provides info not available through standard JSON-RPC API * @param address - Contract address * @returns Creator info or null if not a contract */ @@ -363,7 +361,7 @@ export class OtterscanProvider extends JsonRpcProvider { direction: "before" | "after", startBlock: number, pageSize: number = 500 - ): AsyncGenerator<{ tx: OtsTransaction; receipt: OtsReceipt }, void, unknown> { + ): AsyncGenerator<{ tx: TransactionResponseParams; receipt: OtsTransactionReceiptParams }, void, unknown> { let currentBlock = startBlock; while (true) { From 8a3a0eb85795c91dff1fa0346fc098ea1fe84b52 Mon Sep 17 00:00:00 2001 From: Kyle Thornton Date: Mon, 25 Aug 2025 12:49:26 -0700 Subject: [PATCH 4/5] Creating a proper type for ots_traceTransaction Actually utilizing the OtsAddressTransactionsPage type for searchTransactionsBefore/After Addressing 2 notes from Otterscan maintainer: the page numbers will be ascending/descending based on search direction, and updating the docstrings to note the pageSize limit is a soft limit --- src.ts/_tests/test-providers-otterscan.ts | 50 ++++++++++++++++++-- src.ts/ethers.ts | 2 +- src.ts/providers/index.ts | 2 +- src.ts/providers/provider-otterscan.ts | 56 +++++++++++++---------- 4 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src.ts/_tests/test-providers-otterscan.ts b/src.ts/_tests/test-providers-otterscan.ts index d74eafb444..55a16cc589 100644 --- a/src.ts/_tests/test-providers-otterscan.ts +++ b/src.ts/_tests/test-providers-otterscan.ts @@ -7,6 +7,7 @@ import type { OtsBlockDetails, OtsBlockTransactionsPage, OtsAddressTransactionsPage, + OtsTraceEntry, OtsContractCreator } from "../providers/provider-otterscan.js"; @@ -44,7 +45,32 @@ describe("Test Otterscan Provider", function () { result = "0x"; break; case "ots_traceTransaction": - result = { calls: [] }; + result = [ + { + type: "CALL", + depth: 0, + from: "0x737d16748aa3f93d6ff1b0aefa3eca7fffca868e", + to: "0x545ec8c956d307cc3bf7f9ba1e413217eff1bc7a", + value: "0x0", + input: "0xff02000000000000001596d80c86b939000000000000000019cfaf37a98833fed61000a72a288205ead800f1978fc2d943013f0e9f56d3a1077a294dde1b09bb078844df40758a5d0f9a27017ed0000000011e00000300000191ccf22538c30f09d14be27569c5bdb61f99b3c9f33c0bb40d1bbf6eafaaea2adfb7d2d3ebc1e49c01857e0000000000000000" + }, + { + type: "DELEGATECALL", + depth: 1, + from: "0x545ec8c956d307cc3bf7f9ba1e413217eff1bc7a", + to: "0x998f7f745f61a910da86d8aa65db60b67a40da6d", + value: null, + input: "0x5697217300000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000036a72a288205ead800f1978fc2d943013f0e9f56d3a1077a294dde1b09bb078844df40758a5d0f9a27017ed0000000011e000003000001" + }, + { + type: "STATICCALL", + depth: 2, + from: "0x545ec8c956d307cc3bf7f9ba1e413217eff1bc7a", + to: "0x91ccf22538c30f09d14be27569c5bdb61f99b3c9", + value: null, + input: "0x0902f1ac" + } + ]; break; case "ots_getBlockDetails": result = { @@ -221,8 +247,24 @@ describe("Test Otterscan Provider", function () { it("should trace transaction", async function () { const provider = createMockOtsProvider(); const trace = await provider.traceTransaction("0x123"); - assert(typeof trace === "object", "should return trace object"); - assert(Array.isArray(trace.calls), "should have calls array"); + assert(Array.isArray(trace), "should return array of trace entries"); + assert.strictEqual(trace.length, 3, "should have three trace entries"); + + const firstEntry = trace[0]; + assert.strictEqual(firstEntry.type, "CALL", "first entry should be CALL"); + assert.strictEqual(firstEntry.depth, 0, "first entry should have depth 0"); + assert.strictEqual(firstEntry.from, "0x737d16748aa3f93d6ff1b0aefa3eca7fffca868e", "should have correct from"); + assert.strictEqual(firstEntry.to, "0x545ec8c956d307cc3bf7f9ba1e413217eff1bc7a", "should have correct to"); + assert.strictEqual(firstEntry.value, "0x0", "should have correct value"); + assert(firstEntry.input?.startsWith("0xff02"), "should have input data"); + + const delegateCall = trace[1]; + assert.strictEqual(delegateCall.type, "DELEGATECALL", "second entry should be DELEGATECALL"); + assert.strictEqual(delegateCall.value, null, "delegatecall should have null value"); + + const staticCall = trace[2]; + assert.strictEqual(staticCall.type, "STATICCALL", "third entry should be STATICCALL"); + assert.strictEqual(staticCall.value, null, "staticcall should have null value"); }); it("should get block details", async function () { @@ -319,6 +361,7 @@ describe("Test Otterscan Provider", function () { const blockDetails: OtsBlockDetails = await provider.getBlockDetails(4096); const blockTxs: OtsBlockTransactionsPage = await provider.getBlockTransactions(4096, 0, 10); const searchResults: OtsAddressTransactionsPage = await provider.searchTransactionsBefore("0x123", 4096, 10); + const traceEntries: OtsTraceEntry[] = await provider.traceTransaction("0x123"); const creator: OtsContractCreator | null = await provider.getContractCreator("0x123"); // Basic type assertions @@ -328,6 +371,7 @@ describe("Test Otterscan Provider", function () { assert(typeof blockDetails === "object"); assert(typeof blockTxs === "object" && Array.isArray(blockTxs.transactions)); assert(typeof searchResults === "object" && Array.isArray(searchResults.txs)); + assert(Array.isArray(traceEntries)); assert(creator === null || typeof creator === "object"); }); }); diff --git a/src.ts/ethers.ts b/src.ts/ethers.ts index ef1c350d71..878fb21c0b 100644 --- a/src.ts/ethers.ts +++ b/src.ts/ethers.ts @@ -180,7 +180,7 @@ export type { MinedBlock, MinedTransactionResponse, Networkish, OrphanFilter, OtsTransactionReceiptParams, OtsBlockTransactionReceipt, OtsBlockParams, OtsInternalOp, OtsBlockDetails, OtsBlockTransactionsPage, - OtsAddressTransactionsPage, OtsContractCreator, + OtsAddressTransactionsPage, OtsTraceEntry, OtsContractCreator, PerformActionFilter, PerformActionRequest, PerformActionTransaction, PreparedTransactionRequest, ProviderEvent, Subscriber, Subscription, TopicFilter, TransactionReceiptParams, TransactionRequest, diff --git a/src.ts/providers/index.ts b/src.ts/providers/index.ts index ac66c227e9..39e1dd0140 100644 --- a/src.ts/providers/index.ts +++ b/src.ts/providers/index.ts @@ -62,7 +62,7 @@ export { OtterscanProvider } from "./provider-otterscan.js"; export type { OtsTransactionReceiptParams, OtsBlockTransactionReceipt, OtsBlockParams, OtsInternalOp, OtsBlockDetails, OtsBlockTransactionsPage, - OtsAddressTransactionsPage, OtsContractCreator + OtsAddressTransactionsPage, OtsTraceEntry, OtsContractCreator } from "./provider-otterscan.js"; export { BrowserProvider } from "./provider-browser.js"; diff --git a/src.ts/providers/provider-otterscan.ts b/src.ts/providers/provider-otterscan.ts index 8368b7b49d..591123fb54 100644 --- a/src.ts/providers/provider-otterscan.ts +++ b/src.ts/providers/provider-otterscan.ts @@ -99,6 +99,24 @@ export interface OtsAddressTransactionsPage { lastPage: boolean; } +/** + * Trace entry from ots_traceTransaction + */ +export interface OtsTraceEntry { + /** Type of operation (CALL, DELEGATECALL, STATICCALL, CREATE, etc.) */ + type: string; + /** Call depth in the execution stack */ + depth: number; + /** Source address */ + from: string; + /** Target address */ + to: string; + /** Value transferred (hex string, null for delegate/static calls) */ + value: string | null; + /** Input data for the call */ + input?: string; +} + /** * Contract creator information */ @@ -212,9 +230,9 @@ export class OtterscanProvider extends JsonRpcProvider { /** * Get execution trace for a transaction * @param txHash - Transaction hash - * @returns Trace data + * @returns Array of trace entries showing call execution flow */ - async traceTransaction(txHash: string): Promise { + async traceTransaction(txHash: string): Promise { return await this.send("ots_traceTransaction", [txHash]); } @@ -243,7 +261,7 @@ export class OtterscanProvider extends JsonRpcProvider { * Removes verbose fields like logs from receipts to save bandwidth * @param blockNumber - Block number * @param page - Page number (0-based) - * @param pageSize - Number of transactions per page + * @param pageSize - Soft limit on transactions per page (actual results may exceed this if a block contains more transactions) * @returns Page of transactions and receipts (with logs removed) */ async getBlockTransactions(blockNumber: number, page: number, pageSize: number): Promise { @@ -255,7 +273,7 @@ export class OtterscanProvider extends JsonRpcProvider { * Provides paginated transaction history with in-node search (no external indexer needed) * @param address - Address to search for * @param blockNumber - Starting block number - * @param pageSize - Maximum results to return + * @param pageSize - Soft limit on results to return (actual results may exceed this if a block contains more transactions) * @returns Page of transactions and receipts */ async searchTransactionsBefore( @@ -263,16 +281,11 @@ export class OtterscanProvider extends JsonRpcProvider { blockNumber: number, pageSize: number ): Promise { - const result = (await this.send("ots_searchTransactionsBefore", [address, blockNumber, pageSize])) as { - txs: any[]; - receipts: any[]; - firstPage: boolean; - lastPage: boolean; - }; + const result = await this.send("ots_searchTransactionsBefore", [address, blockNumber, pageSize]) as OtsAddressTransactionsPage; return { ...result, - txs: result.txs.map((tx: any) => formatTransactionResponse(tx)), - receipts: result.receipts.map((receipt: any) => ({ + txs: result.txs.map(tx => formatTransactionResponse(tx)), + receipts: result.receipts.map(receipt => ({ ...formatTransactionReceipt(receipt), timestamp: receipt.timestamp })) @@ -284,7 +297,7 @@ export class OtterscanProvider extends JsonRpcProvider { * Provides paginated transaction history with in-node search (no external indexer needed) * @param address - Address to search for * @param blockNumber - Starting block number - * @param pageSize - Maximum results to return + * @param pageSize - Soft limit on results to return (actual results may exceed this if a block contains more transactions) * @returns Page of transactions and receipts */ async searchTransactionsAfter( @@ -292,16 +305,11 @@ export class OtterscanProvider extends JsonRpcProvider { blockNumber: number, pageSize: number ): Promise { - const result = (await this.send("ots_searchTransactionsAfter", [address, blockNumber, pageSize])) as { - txs: any[]; - receipts: any[]; - firstPage: boolean; - lastPage: boolean; - }; + const result = await this.send("ots_searchTransactionsAfter", [address, blockNumber, pageSize]) as OtsAddressTransactionsPage; return { ...result, - txs: result.txs.map((tx: any) => formatTransactionResponse(tx)), - receipts: result.receipts.map((receipt: any) => ({ + txs: result.txs.map(tx => formatTransactionResponse(tx)), + receipts: result.receipts.map(receipt => ({ ...formatTransactionReceipt(receipt), timestamp: receipt.timestamp })) @@ -353,14 +361,14 @@ export class OtterscanProvider extends JsonRpcProvider { * @param address - Address to search * @param direction - Search direction ("before" or "after") * @param startBlock - Starting block number - * @param pageSize - Results per page (default: 500) + * @param pageSize - Soft limit on results per page (default: 25, actual results may exceed this if a block contains more transactions) * @yields Object with tx and receipt for each transaction */ async *iterateAddressHistory( address: string, direction: "before" | "after", startBlock: number, - pageSize: number = 500 + pageSize: number = 25 ): AsyncGenerator<{ tx: TransactionResponseParams; receipt: OtsTransactionReceiptParams }, void, unknown> { let currentBlock = startBlock; @@ -379,7 +387,7 @@ export class OtterscanProvider extends JsonRpcProvider { } // Check if we've reached the end - if (page.lastPage) break; + if (direction === "before" ? page.lastPage : page.firstPage) break; // Update block cursor for next iteration const lastTx = page.txs[page.txs.length - 1]; From 6b4a9cf2047c71b6f49a75d70efae0373b9ca8dc Mon Sep 17 00:00:00 2001 From: Kyle Thornton Date: Tue, 26 Aug 2025 12:21:34 -0700 Subject: [PATCH 5/5] Simplifying iterateAddressHistory to be more opinionated about direction and pageSize since it's a helper method anyway. --- src.ts/_tests/test-providers-otterscan.ts | 2 +- src.ts/providers/provider-otterscan.ts | 50 +++++++++++++---------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src.ts/_tests/test-providers-otterscan.ts b/src.ts/_tests/test-providers-otterscan.ts index 55a16cc589..446b4f9589 100644 --- a/src.ts/_tests/test-providers-otterscan.ts +++ b/src.ts/_tests/test-providers-otterscan.ts @@ -346,7 +346,7 @@ describe("Test Otterscan Provider", function () { it("should have async iterator for address history", function () { const provider = createMockOtsProvider(); - const iterator = provider.iterateAddressHistory("0x123", "before", 4096); + const iterator = provider.iterateAddressHistory("0x123", 4000, 4096); assert(typeof iterator[Symbol.asyncIterator] === "function", "should be async iterable"); }); diff --git a/src.ts/providers/provider-otterscan.ts b/src.ts/providers/provider-otterscan.ts index 591123fb54..7d9cb4592f 100644 --- a/src.ts/providers/provider-otterscan.ts +++ b/src.ts/providers/provider-otterscan.ts @@ -21,6 +21,9 @@ import type { FetchRequest } from "../utils/index.js"; import type { BlockParams, TransactionReceiptParams, TransactionResponseParams } from "./formatting.js"; import type { Fragment } from "../abi/index.js"; +// Maximum page size for Otterscan queries - API limitation +const OTS_MAX_PAGE_SIZE = 25; + // Formatted Otterscan receipt (extends standard receipt with timestamp) export interface OtsTransactionReceiptParams extends TransactionReceiptParams { timestamp: number; // Otterscan adds a Unix timestamp @@ -357,39 +360,43 @@ export class OtterscanProvider extends JsonRpcProvider { } /** - * Iterate through transaction history for an address + * Iterate through transaction history for an address between block ranges * @param address - Address to search - * @param direction - Search direction ("before" or "after") - * @param startBlock - Starting block number - * @param pageSize - Soft limit on results per page (default: 25, actual results may exceed this if a block contains more transactions) - * @yields Object with tx and receipt for each transaction + * @param startBlock - Starting block number (inclusive) + * @param endBlock - Ending block number (inclusive) + * @yields Object with tx and receipt for each transaction in ascending block order */ async *iterateAddressHistory( address: string, - direction: "before" | "after", startBlock: number, - pageSize: number = 25 + endBlock: number ): AsyncGenerator<{ tx: TransactionResponseParams; receipt: OtsTransactionReceiptParams }, void, unknown> { let currentBlock = startBlock; - while (true) { - const page = - direction === "before" - ? await this.searchTransactionsBefore(address, currentBlock, pageSize) - : await this.searchTransactionsAfter(address, currentBlock, pageSize); + while (currentBlock <= endBlock) { + const page = await this.searchTransactionsAfter(address, currentBlock, OTS_MAX_PAGE_SIZE); - // Yield each transaction with its receipt + // Filter and yield transactions within our range for (let i = 0; i < page.txs.length; i++) { - yield { - tx: page.txs[i], - receipt: page.receipts[i] - }; + const tx = page.txs[i]; + const blockNum = Number(tx.blockNumber); + + // Only yield transactions within the specified range + if (blockNum >= startBlock && blockNum <= endBlock) { + yield { + tx: tx, + receipt: page.receipts[i] + }; + } + + // Stop if we've gone past the end block + if (blockNum > endBlock) return; } - // Check if we've reached the end - if (direction === "before" ? page.lastPage : page.firstPage) break; + // Check if we've reached the end of available data + if (page.lastPage) break; - // Update block cursor for next iteration + // Move to the next block after the last transaction we saw const lastTx = page.txs[page.txs.length - 1]; if (!lastTx) break; @@ -400,7 +407,8 @@ export class OtterscanProvider extends JsonRpcProvider { throw new Error(`Iterator stuck on block ${currentBlock}. API returned same block number.`); } - currentBlock = nextBlock; + // Move cursor forward + currentBlock = nextBlock + 1; } } }