import { ChangeEventHandler, FormEventHandler, RefObject, useContext, useRef, useState, } from "react"; import { NavigateFunction, useNavigate } from "react-router"; import { JsonRpcProvider, TransactionResponse } from "@ethersproject/providers"; import { isAddress } from "@ethersproject/address"; import { isHexString } from "@ethersproject/bytes"; import useKeyboardShortcut from "use-keyboard-shortcut"; import { PAGE_SIZE } from "../params"; import { ProcessedTransaction, TransactionChunk } from "../types"; import { RuntimeContext } from "../useRuntime"; import { getTransactionBySenderAndNonceFetcher } from "../useErigonHooks"; export class SearchController { private txs: ProcessedTransaction[]; private pageStart: number; private pageEnd: number; private constructor( readonly address: string, txs: ProcessedTransaction[], readonly isFirst: boolean, readonly isLast: boolean, boundToStart: boolean ) { this.txs = txs; if (boundToStart) { this.pageStart = 0; this.pageEnd = Math.min(txs.length, PAGE_SIZE); } else { this.pageEnd = txs.length; this.pageStart = Math.max(0, txs.length - PAGE_SIZE); } } private static rawToProcessed = (provider: JsonRpcProvider, _rawRes: any) => { const _res: TransactionResponse[] = _rawRes.txs.map((t: any) => provider.formatter.transactionResponse(t) ); return { txs: _res.map((t, i): ProcessedTransaction => { const _rawReceipt = _rawRes.receipts[i]; const _receipt = provider.formatter.receipt(_rawReceipt); return { blockNumber: t.blockNumber!, timestamp: provider.formatter.number(_rawReceipt.timestamp), idx: _receipt.transactionIndex, hash: t.hash, from: t.from, to: t.to ?? null, createdContractAddress: _receipt.contractAddress, value: t.value, fee: _receipt.gasUsed.mul(t.gasPrice!), gasPrice: t.gasPrice!, data: t.data, status: _receipt.status!, }; }), firstPage: _rawRes.firstPage, lastPage: _rawRes.lastPage, }; }; private static async readBackPage( provider: JsonRpcProvider, address: string, baseBlock: number ): Promise { const _rawRes = await provider.send("ots_searchTransactionsBefore", [ address, baseBlock, PAGE_SIZE, ]); return this.rawToProcessed(provider, _rawRes); } private static async readForwardPage( provider: JsonRpcProvider, address: string, baseBlock: number ): Promise { const _rawRes = await provider.send("ots_searchTransactionsAfter", [ address, baseBlock, PAGE_SIZE, ]); return this.rawToProcessed(provider, _rawRes); } static async firstPage( provider: JsonRpcProvider, address: string ): Promise { const newTxs = await SearchController.readBackPage(provider, address, 0); return new SearchController( address, newTxs.txs, newTxs.firstPage, newTxs.lastPage, true ); } static async middlePage( provider: JsonRpcProvider, address: string, hash: string, next: boolean ): Promise { const tx = await provider.getTransaction(hash); const newTxs = next ? await SearchController.readBackPage(provider, address, tx.blockNumber!) : await SearchController.readForwardPage( provider, address, tx.blockNumber! ); return new SearchController( address, newTxs.txs, newTxs.firstPage, newTxs.lastPage, next ); } static async lastPage( provider: JsonRpcProvider, address: string ): Promise { const newTxs = await SearchController.readForwardPage(provider, address, 0); return new SearchController( address, newTxs.txs, newTxs.firstPage, newTxs.lastPage, false ); } getPage(): ProcessedTransaction[] { return this.txs.slice(this.pageStart, this.pageEnd); } async prevPage( provider: JsonRpcProvider, hash: string ): Promise { // Already on this page if (this.txs[this.pageEnd - 1].hash === hash) { return this; } if (this.txs[this.pageStart].hash === hash) { const overflowPage = this.txs.slice(0, this.pageStart); const baseBlock = this.txs[0].blockNumber; const prevPage = await SearchController.readForwardPage( provider, this.address, baseBlock ); return new SearchController( this.address, prevPage.txs.concat(overflowPage), prevPage.firstPage, prevPage.lastPage, false ); } return this; } async nextPage( provider: JsonRpcProvider, hash: string ): Promise { // Already on this page if (this.txs[this.pageStart].hash === hash) { return this; } if (this.txs[this.pageEnd - 1].hash === hash) { const overflowPage = this.txs.slice(this.pageEnd); const baseBlock = this.txs[this.txs.length - 1].blockNumber; const nextPage = await SearchController.readBackPage( provider, this.address, baseBlock ); return new SearchController( this.address, overflowPage.concat(nextPage.txs), nextPage.firstPage, nextPage.lastPage, true ); } return this; } } const doSearch = async ( provider: JsonRpcProvider, q: string, navigate: NavigateFunction ) => { // Cleanup q = q.trim(); let maybeAddress = q; let maybeIndex: string | undefined; const sepIndex = q.lastIndexOf(":"); if (sepIndex !== -1) { maybeAddress = q.substring(0, sepIndex); maybeIndex = q.substring(sepIndex + 1); } if (isAddress(maybeAddress)) { // Plain address + nonce? if (await navigateToTx(provider, maybeAddress, maybeIndex, navigate)) { return; } // Plain address navigate(`/address/${maybeAddress}`, { replace: true }); return; } if (isHexString(q, 32)) { navigate(`/tx/${q}`, { replace: true }); return; } const blockNumber = parseInt(q); if (!isNaN(blockNumber)) { navigate(`/block/${blockNumber}`, { replace: true }); return; } // Assume it is an ENS name const resolvedName = await provider.resolveName(maybeAddress); if (resolvedName !== null) { // ENS name + nonce? if (await navigateToTx(provider, resolvedName, maybeIndex, navigate)) { return; } // ENS name navigate(`/address/${maybeAddress}`, { replace: true }); return; } // TODO: handle default }; const navigateToTx = async ( provider: JsonRpcProvider, maybeAddress: string, maybeIndex: string | undefined, navigate: NavigateFunction ): Promise => { if (maybeIndex !== undefined) { try { let nonce = 0; if (maybeIndex === "latest") { const count = await provider.getTransactionCount(maybeAddress); if (count > 0) { nonce = count - 1; } } else { nonce = parseInt(maybeIndex); } const txHash = await getTransactionBySenderAndNonceFetcher({ provider, sender: maybeAddress, nonce, }); if (txHash) { navigate(`/tx/${txHash}`, { replace: true }); } return true; } catch (err) { // ignore } } return false; }; export const useGenericSearch = (): [ RefObject, ChangeEventHandler, FormEventHandler ] => { const { provider } = useContext(RuntimeContext); const [searchString, setSearchString] = useState(""); const [canSubmit, setCanSubmit] = useState(false); const navigate = useNavigate(); const handleChange: React.ChangeEventHandler = (e) => { const searchTerm = e.target.value.trim(); setCanSubmit(searchTerm.length > 0); setSearchString(searchTerm); }; const handleSubmit: React.FormEventHandler = (e) => { e.preventDefault(); if (!canSubmit || !provider) { return; } if (searchRef.current) { searchRef.current.value = ""; } doSearch(provider, searchString, navigate); }; const searchRef = useRef(null); useKeyboardShortcut(["/"], () => { searchRef.current?.focus(); }); return [searchRef, handleChange, handleSubmit]; };