diff --git a/src.ts/_tests/test-providers-otterscan.ts b/src.ts/_tests/test-providers-otterscan.ts new file mode 100644 index 0000000000..446b4f9589 --- /dev/null +++ b/src.ts/_tests/test-providers-otterscan.ts @@ -0,0 +1,377 @@ +import assert from "assert"; + +import { FetchRequest, OtterscanProvider, JsonRpcProvider } from "../index.js"; + +import type { + OtsInternalOp, + OtsBlockDetails, + OtsBlockTransactionsPage, + OtsAddressTransactionsPage, + OtsTraceEntry, + OtsContractCreator +} from "../providers/provider-otterscan.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 = [ + { + 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 = { + block: { + hash: "0x123abc", + number: "0x1000", + timestamp: "0x499602d2", + parentHash: "0x000abc", + nonce: "0x0", + difficulty: "0x0", + gasLimit: "0x1c9c380", + gasUsed: "0x5208", + miner: "0x0000000000000000000000000000000000000000", + extraData: "0x", + baseFeePerGas: "0x0", + logsBloom: null + }, + 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: [ + { + 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 + }; + 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(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 () { + const provider = createMockOtsProvider(); + const blockDetails = await provider.getBlockDetails(4096); + + assert(typeof blockDetails === "object", "should return object"); + 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 () { + 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", 4000, 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: 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 + 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(Array.isArray(traceEntries)); + assert(creator === null || typeof creator === "object"); + }); +}); diff --git a/src.ts/ethers.ts b/src.ts/ethers.ts index a88b6331fa..878fb21c0b 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,8 +175,12 @@ export type { ContractRunner, DebugEventBrowserProvider, Eip1193Provider, Eip6963ProviderInfo, EventFilter, Filter, FilterByBlockHash, GasCostParameters, JsonRpcApiProviderOptions, JsonRpcError, - JsonRpcPayload, JsonRpcResult, JsonRpcTransactionRequest, LogParams, + JsonRpcPayload, JsonRpcResult, JsonRpcTransactionRequest, + LogParams, MinedBlock, MinedTransactionResponse, Networkish, OrphanFilter, + OtsTransactionReceiptParams, OtsBlockTransactionReceipt, + OtsBlockParams, OtsInternalOp, OtsBlockDetails, OtsBlockTransactionsPage, + 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 27a5460e2c..39e1dd0140 100644 --- a/src.ts/providers/index.ts +++ b/src.ts/providers/index.ts @@ -57,6 +57,14 @@ export { export { FallbackProvider } from "./provider-fallback.js"; export { JsonRpcApiProvider, JsonRpcProvider, JsonRpcSigner } from "./provider-jsonrpc.js" +export { OtterscanProvider } from "./provider-otterscan.js"; + +export type { + OtsTransactionReceiptParams, OtsBlockTransactionReceipt, + OtsBlockParams, OtsInternalOp, OtsBlockDetails, OtsBlockTransactionsPage, + OtsAddressTransactionsPage, OtsTraceEntry, OtsContractCreator +} from "./provider-otterscan.js"; + export { BrowserProvider } from "./provider-browser.js"; export { AlchemyProvider } from "./provider-alchemy.js"; @@ -127,6 +135,7 @@ export type { JsonRpcTransactionRequest, } from "./provider-jsonrpc.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..76829184ea --- /dev/null +++ b/src.ts/providers/provider-otterscan.ts @@ -0,0 +1,419 @@ +/** + * 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. + * + * @_subsection: api/providers/thirdparty:Otterscan [providers-otterscan] + */ + +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, TransactionReceiptParams, TransactionResponseParams } from "./formatting.js"; +import type { Fragment } from "../abi/index.js"; + +// Otterscan default maximum page size for queries +// This can be changed: https://docs.otterscan.io/install/erigon-optional +const OTS_DEFAULT_PAGE_SIZE = 25; + +// Formatted Otterscan receipt (extends standard receipt with timestamp) +export interface OtsTransactionReceiptParams extends TransactionReceiptParams { + timestamp: number; // Otterscan adds a Unix timestamp +} + +/** + * 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: 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 { + /** Block data with transactions list removed and log blooms set to null for efficiency */ + block: OtsBlockParams; + /** Block issuance information */ + issuance?: { + blockReward: string; + uncleReward: string; + issuance: string; + }; + /** Total fees collected in the block */ + totalFees?: string; +} + +/** + * Receipt for block transactions (logs and logsBloom set to null for efficiency) + */ +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 for bandwidth efficiency */ + receipts: Array; +} + +/** + * Paginated search results for address transaction history (uses standard ethers types) + */ +export interface OtsAddressTransactionsPage { + /** Array of transactions */ + txs: TransactionResponseParams[]; + /** Array of corresponding receipts with timestamps */ + receipts: OtsTransactionReceiptParams[]; + /** Whether this is the first page */ + firstPage: boolean; + /** Whether this is the last page */ + 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 + */ +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 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 (is a contract) + */ + 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?: Fragment[]): 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 Array of trace entries showing call execution flow + */ + async traceTransaction(txHash: string): Promise { + return await this.send("ots_traceTransaction", [txHash]); + } + + /** + * 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 + */ + async getBlockDetails(blockNumber: number): Promise { + 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 - 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 { + return await this.send("ots_getBlockTransactions", [blockNumber, page, pageSize]); + } + + /** + * 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 - 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( + address: string, + blockNumber: number, + pageSize: number + ): Promise { + const result = await this.send("ots_searchTransactionsBefore", [address, blockNumber, pageSize]) as OtsAddressTransactionsPage; + return { + ...result, + txs: result.txs.map(tx => formatTransactionResponse(tx)), + receipts: result.receipts.map(receipt => ({ + ...formatTransactionReceipt(receipt), + timestamp: receipt.timestamp + })) + }; + } + + /** + * 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 - 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( + address: string, + blockNumber: number, + pageSize: number + ): Promise { + const result = await this.send("ots_searchTransactionsAfter", [address, blockNumber, pageSize]) as OtsAddressTransactionsPage; + return { + ...result, + txs: result.txs.map(tx => formatTransactionResponse(tx)), + receipts: result.receipts.map(receipt => ({ + ...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 + */ + async getTransactionBySenderAndNonce(sender: string, nonce: number): Promise { + return await this.send("ots_getTransactionBySenderAndNonce", [sender, nonce]); + } + + /** + * 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 + */ + 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: 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; + } + } + + /** + * Iterate through transaction history for an address between block ranges + * @param address - Address to search + * @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, + startBlock: number, + endBlock: number, + pageSize: number = OTS_DEFAULT_PAGE_SIZE + ): AsyncGenerator<{ tx: TransactionResponseParams; receipt: OtsTransactionReceiptParams }, void, unknown> { + let currentBlock = startBlock; + + while (currentBlock <= endBlock) { + const page = await this.searchTransactionsAfter(address, currentBlock, pageSize); + + // Sort by block number ascending (API returns descending) + const sortedIndices = page.txs + .map((tx, index) => ({ blockNum: Number(tx.blockNumber), index })) + .sort((a, b) => a.blockNum - b.blockNum) + .map(item => item.index); + + for (const i of sortedIndices) { + const tx = page.txs[i]; + const blockNum = Number(tx.blockNumber); + + if (blockNum >= startBlock && blockNum <= endBlock) { + yield { + tx: tx, + receipt: page.receipts[i] + }; + } + + if (blockNum > endBlock) return; + } + + // Check if we've reached the end of available data + if (page.lastPage) break; + + // Move to the next block after the last transaction we saw + 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.`); + } + + // Move cursor forward + currentBlock = nextBlock + 1; + } + } +}