diff --git a/src/TokenTransferItem.tsx b/src/TokenTransferItem.tsx index 5b9fc72..c56bc26 100644 --- a/src/TokenTransferItem.tsx +++ b/src/TokenTransferItem.tsx @@ -15,7 +15,7 @@ import { Metadata } from "./useSourcify"; type TokenTransferItemProps = { t: TokenTransfer; - tokenMeta?: TokenMeta | undefined; + tokenMeta?: TokenMeta | null | undefined; resolvedAddresses: ResolvedAddresses | undefined; metadatas: Record; }; diff --git a/src/Transaction.tsx b/src/Transaction.tsx index bd4cbaa..168c83f 100644 --- a/src/Transaction.tsx +++ b/src/Transaction.tsx @@ -30,6 +30,12 @@ const Logs = React.lazy( /* webpackChunkName: "txlogs", webpackPrefetch: true */ "./transaction/Logs" ) ); +const Trace = React.lazy( + () => + import( + /* webpackChunkName: "txtrace", webpackPrefetch: true */ "./transaction/Trace" + ) +); type TransactionParams = { txhash: string; @@ -98,6 +104,7 @@ const Transaction: React.FC = () => { {txData && ` (${txData.confirmedData?.logs?.length ?? 0})`} )} + Trace @@ -121,6 +128,12 @@ const Transaction: React.FC = () => { resolvedAddresses={resolvedAddresses} /> + + + diff --git a/src/api/address-resolver/ERCTokenResolver.ts b/src/api/address-resolver/ERCTokenResolver.ts index 02625dc..f087ec6 100644 --- a/src/api/address-resolver/ERCTokenResolver.ts +++ b/src/api/address-resolver/ERCTokenResolver.ts @@ -11,11 +11,17 @@ export class ERCTokenResolver implements IAddressResolver { ): Promise { const erc20Contract = new Contract(address, erc20, provider); try { - const [name, symbol, decimals] = await Promise.all([ + const [name, symbol, decimals] = (await Promise.all([ erc20Contract.name(), erc20Contract.symbol(), erc20Contract.decimals(), - ]); + ])) as [string, string, number]; + + // Prevent faulty tokens with empty name/symbol + if (!name.trim() || !symbol.trim()) { + return undefined; + } + return { name, symbol, diff --git a/src/components/AddressHighlighter.tsx b/src/components/AddressHighlighter.tsx index e8078d6..9c2c4c3 100644 --- a/src/components/AddressHighlighter.tsx +++ b/src/components/AddressHighlighter.tsx @@ -1,40 +1,22 @@ -import React, { useMemo } from "react"; -import { useSelectionContext } from "../useSelection"; +import React from "react"; +import SelectionHighlighter, { addressSelector } from "./SelectionHighlighter"; type AddressHighlighterProps = React.PropsWithChildren<{ address: string; }>; +// TODO: replace all occurences with SelectionHighlighter and remove this component const AddressHighlighter: React.FC = ({ address, children, -}) => { - const [selection, setSelection] = useSelectionContext(); - const [select, deselect] = useMemo(() => { - const _select = () => { - setSelection({ type: "address", content: address }); - }; - const _deselect = () => { - setSelection(null); - }; - return [_select, _deselect]; - }, [setSelection, address]); +}) => ( + + {children} + +); - return ( -
- {children} -
- ); -}; - -export default React.memo(AddressHighlighter); +export default AddressHighlighter; diff --git a/src/components/AddressLink.tsx b/src/components/AddressLink.tsx new file mode 100644 index 0000000..4b6f99e --- /dev/null +++ b/src/components/AddressLink.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; + +type AddressLinkProps = { + address: string; + text?: string; + dontOverrideColors?: boolean; +}; + +const AddressLink: React.FC = ({ + address, + text, + dontOverrideColors, +}) => ( + + + {text ?? address} + + +); + +export default AddressLink; diff --git a/src/components/ENSNameLink.tsx b/src/components/ENSNameLink.tsx new file mode 100644 index 0000000..ad5df4d --- /dev/null +++ b/src/components/ENSNameLink.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; +import ENSLogo from "./ensLogo.svg"; + +type ENSNameLinkProps = { + name: string; + address: string; + dontOverrideColors?: boolean; +}; + +const ENSNameLink: React.FC = ({ + name, + address, + dontOverrideColors, +}) => ( + + ENS Logo + {name} + +); + +export default ENSNameLink; diff --git a/src/components/ModeTab.tsx b/src/components/ModeTab.tsx index 14a33fd..52ec3f9 100644 --- a/src/components/ModeTab.tsx +++ b/src/components/ModeTab.tsx @@ -1,13 +1,20 @@ import React from "react"; import { Tab } from "@headlessui/react"; -const ModeTab: React.FC = ({ children }) => ( +type ModeTabProps = { + disabled?: boolean | undefined; +}; + +const ModeTab: React.FC = ({ disabled, children }) => ( - `border rounded-lg px-2 py-1 bg-gray-100 hover:bg-gray-200 hover:shadow text-xs text-gray-500 hover:text-gray-600 ${ - selected ? "border-blue-300" : "" - }` + `border rounded-lg px-2 py-1 bg-gray-100 ${ + disabled + ? "border-gray-100 text-gray-300 cursor-default" + : "hover:bg-gray-200 hover:shadow text-gray-500 hover:text-gray-600" + } text-xs ${selected ? "border-blue-300" : ""}` } + disabled={disabled} > {children} diff --git a/src/components/SelectionHighlighter.tsx b/src/components/SelectionHighlighter.tsx new file mode 100644 index 0000000..40a3431 --- /dev/null +++ b/src/components/SelectionHighlighter.tsx @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import { + useSelectionContext, + OptionalSelection, + SelectionType, +} from "../useSelection"; + +export type ContentSelector = ( + selection: OptionalSelection, + content: string +) => boolean; + +export const genericSelector = + (type: SelectionType) => + (selection: OptionalSelection, content: string): boolean => + selection !== null && + selection.type === type && + selection.content === content; + +export const addressSelector: ContentSelector = genericSelector("address"); +export const functionSigSelector: ContentSelector = + genericSelector("functionSig"); + +type SelectionHighlighterProps = React.PropsWithChildren<{ + myType: SelectionType; + myContent: string; + selector: ContentSelector; +}>; + +const SelectionHighlighter: React.FC = ({ + myType, + myContent, + selector, + children, +}) => { + const [selection, setSelection] = useSelectionContext(); + const [select, deselect] = useMemo(() => { + const _select = () => { + setSelection({ type: myType, content: myContent }); + }; + const _deselect = () => { + setSelection(null); + }; + return [_select, _deselect]; + }, [setSelection, myType, myContent]); + + return ( + + {children} + + ); +}; + +type HighlighterBoxProps = { + selected: boolean; + select: () => void; + deselect: () => void; +}; + +const HighlighterBox: React.FC = React.memo( + ({ selected, select, deselect, children }) => ( +
+ {children} +
+ ) +); + +export default SelectionHighlighter; diff --git a/src/components/ValueHighlighter.tsx b/src/components/ValueHighlighter.tsx index c15b514..b9c813f 100644 --- a/src/components/ValueHighlighter.tsx +++ b/src/components/ValueHighlighter.tsx @@ -6,6 +6,7 @@ type ValueHighlighterProps = React.PropsWithChildren<{ value: BigNumber; }>; +// TODO: replace all occurences with SelectionHighlighter and remove this component const ValueHighlighter: React.FC = ({ value, children, diff --git a/src/params.ts b/src/params.ts index 2bfd757..5c95cfa 100644 --- a/src/params.ts +++ b/src/params.ts @@ -1,3 +1,3 @@ -export const MIN_API_LEVEL = 2; +export const MIN_API_LEVEL = 3; export const PAGE_SIZE = 25; diff --git a/src/transaction/Details.tsx b/src/transaction/Details.tsx index 0b474af..e2613be 100644 --- a/src/transaction/Details.tsx +++ b/src/transaction/Details.tsx @@ -1,9 +1,5 @@ import React, { useContext, useMemo } from "react"; -import { - TransactionDescription, - Fragment, - Interface, -} from "@ethersproject/abi"; +import { TransactionDescription } from "@ethersproject/abi"; import { BigNumber } from "@ethersproject/bignumber"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle"; @@ -36,7 +32,11 @@ import ExternalLink from "../components/ExternalLink"; import RelativePosition from "../components/RelativePosition"; import PercentagePosition from "../components/PercentagePosition"; import InputDecoder from "./decoder/InputDecoder"; -import { rawInputTo4Bytes, use4Bytes } from "../use4Bytes"; +import { + rawInputTo4Bytes, + use4Bytes, + useTransactionDescription, +} from "../use4Bytes"; import { DevDoc, useMultipleMetadata, UserDoc } from "../useSourcify"; import { ResolvedAddresses } from "../api/address-resolver"; import { RuntimeContext } from "../useRuntime"; @@ -69,18 +69,11 @@ const Details: React.FC = ({ const fourBytes = txData.to !== null ? rawInputTo4Bytes(txData.data) : "0x"; const fourBytesEntry = use4Bytes(fourBytes); - const fourBytesTxDesc = useMemo(() => { - if (!fourBytesEntry) { - return fourBytesEntry; - } - if (!txData || !fourBytesEntry.signature) { - return undefined; - } - const sig = fourBytesEntry?.signature; - const functionFragment = Fragment.fromString(`function ${sig}`); - const intf = new Interface([functionFragment]); - return intf.parseTransaction({ data: txData.data, value: txData.value }); - }, [txData, fourBytesEntry]); + const fourBytesTxDesc = useTransactionDescription( + fourBytesEntry, + txData.data, + txData.value + ); const resolvedTxDesc = txDesc ?? fourBytesTxDesc; const userMethod = txDesc ? userDoc?.methods[txDesc.signature] : undefined; diff --git a/src/transaction/FunctionSignature.tsx b/src/transaction/FunctionSignature.tsx new file mode 100644 index 0000000..718138e --- /dev/null +++ b/src/transaction/FunctionSignature.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import SelectionHighlighter, { + functionSigSelector, +} from "../components/SelectionHighlighter"; + +type FunctionSignatureProps = { + callType: string; + sig: string; +}; + +const FunctionSignature: React.FC = ({ + callType, + sig, +}) => ( + + + {sig} + + +); + +export default FunctionSignature; diff --git a/src/transaction/Trace.tsx b/src/transaction/Trace.tsx new file mode 100644 index 0000000..5002391 --- /dev/null +++ b/src/transaction/Trace.tsx @@ -0,0 +1,69 @@ +import React, { useContext, useMemo } from "react"; +import ContentFrame from "../ContentFrame"; +import TransactionAddress from "../components/TransactionAddress"; +import TraceItem from "./TraceItem"; +import { TransactionData } from "../types"; +import { useBatch4Bytes } from "../use4Bytes"; +import { useTraceTransaction, useUniqueSignatures } from "../useErigonHooks"; +import { RuntimeContext } from "../useRuntime"; +import { ResolvedAddresses } from "../api/address-resolver"; +import { tracesCollector, useResolvedAddresses } from "../useResolvedAddresses"; + +type TraceProps = { + txData: TransactionData; + resolvedAddresses: ResolvedAddresses | undefined; +}; + +const Trace: React.FC = ({ txData, resolvedAddresses }) => { + const { provider } = useContext(RuntimeContext); + const traces = useTraceTransaction(provider, txData.transactionHash); + const uniqueSignatures = useUniqueSignatures(traces); + const sigMap = useBatch4Bytes(uniqueSignatures); + + const addrCollector = useMemo(() => tracesCollector(traces), [traces]); + const traceResolvedAddresses = useResolvedAddresses(provider, addrCollector); + const mergedResolvedAddresses = useMemo(() => { + const merge = {}; + if (resolvedAddresses) { + Object.assign(merge, resolvedAddresses); + } + if (traceResolvedAddresses) { + Object.assign(merge, traceResolvedAddresses); + } + return merge; + }, [resolvedAddresses, traceResolvedAddresses]); + + return ( + +
+ {traces ? ( + <> +
+ +
+
+ {traces.map((t, i, a) => ( + + ))} +
+ + ) : ( +
+
+
+ )} +
+
+ ); +}; + +export default React.memo(Trace); diff --git a/src/transaction/TraceInput.tsx b/src/transaction/TraceInput.tsx new file mode 100644 index 0000000..667977e --- /dev/null +++ b/src/transaction/TraceInput.tsx @@ -0,0 +1,113 @@ +import React, { useState } from "react"; +import { Switch } from "@headlessui/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBomb } from "@fortawesome/free-solid-svg-icons/faBomb"; +import TransactionAddress from "../components/TransactionAddress"; +import FormattedBalance from "../components/FormattedBalance"; +import FunctionSignature from "./FunctionSignature"; +import InputDecoder from "./decoder/InputDecoder"; +import { TraceEntry } from "../useErigonHooks"; +import { ResolvedAddresses } from "../api/address-resolver"; +import { + extract4Bytes, + FourBytesEntry, + useTransactionDescription, +} from "../use4Bytes"; + +type TraceInputProps = { + t: TraceEntry; + fourBytesMap: Record; + resolvedAddresses: ResolvedAddresses | undefined; +}; + +const TraceInput: React.FC = ({ + t, + fourBytesMap, + resolvedAddresses, +}) => { + const raw4Bytes = extract4Bytes(t.input); + const fourBytes = raw4Bytes !== null ? fourBytesMap[raw4Bytes] : null; + const sigText = + raw4Bytes === null ? "" : fourBytes?.name ?? raw4Bytes; + const hasParams = t.input.length > 10; + + const fourBytesTxDesc = useTransactionDescription( + fourBytes, + t.input, + t.value + ); + + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ {t.type} + {t.type === "SELFDESTRUCT" ? ( + + + + ) : ( + <> + + + + {t.type !== "CREATE" && t.type !== "CREATE2" && ( + <> + . + + {t.value && !t.value.isZero() && ( + + {"{"}value: ETH{"}"} + + )} + + ( + {hasParams && ( + + {expanded ? ( + [-] + ) : ( + <>[...] + )} + + )} + {(!hasParams || !expanded) && <>)} + + + )} + + )} +
+ {hasParams && expanded && ( + <> +
+ +
+
)
+ + )} +
+ ); +}; + +export default TraceInput; diff --git a/src/transaction/TraceItem.tsx b/src/transaction/TraceItem.tsx new file mode 100644 index 0000000..60e4d85 --- /dev/null +++ b/src/transaction/TraceItem.tsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlusSquare } from "@fortawesome/free-regular-svg-icons/faPlusSquare"; +import { faMinusSquare } from "@fortawesome/free-regular-svg-icons/faMinusSquare"; +import { Switch } from "@headlessui/react"; +import { FourBytesEntry } from "../use4Bytes"; +import { TraceGroup } from "../useErigonHooks"; +import { ResolvedAddresses } from "../api/address-resolver"; +import TraceInput from "./TraceInput"; + +type TraceItemProps = { + t: TraceGroup; + last: boolean; + fourBytesMap: Record; + resolvedAddresses: ResolvedAddresses | undefined; +}; + +const TraceItem: React.FC = ({ + t, + last, + fourBytesMap, + resolvedAddresses, +}) => { + const [expanded, setExpanded] = useState(true); + + return ( + <> +
+
+ {!last && ( +
+ )} + {t.children && ( + + + + )} + +
+ {t.children && ( +
+ +
+ )} + + ); +}; + +type TraceChildrenProps = { + c: TraceGroup[]; + fourBytesMap: Record; + resolvedAddresses: ResolvedAddresses | undefined; +}; + +const TraceChildren: React.FC = React.memo( + ({ c, fourBytesMap, resolvedAddresses }) => { + return ( + <> + {c.map((tc, i, a) => ( + + ))} + + ); + } +); + +export default TraceItem; diff --git a/src/transaction/decoder/AddressDecoder.tsx b/src/transaction/decoder/AddressDecoder.tsx index ae43ee1..49726e6 100644 --- a/src/transaction/decoder/AddressDecoder.tsx +++ b/src/transaction/decoder/AddressDecoder.tsx @@ -1,10 +1,10 @@ import React from "react"; +import TransactionAddress from "../../components/TransactionAddress"; import Copy from "../../components/Copy"; import { ResolvedAddresses } from "../../api/address-resolver"; -import TransactionAddress from "../../components/TransactionAddress"; type AddressDecoderProps = { - r: any; + r: string; resolvedAddresses?: ResolvedAddresses | undefined; }; @@ -13,12 +13,9 @@ const AddressDecoder: React.FC = ({ resolvedAddresses, }) => (
- - + +
); -export default React.memo(AddressDecoder); +export default AddressDecoder; diff --git a/src/transaction/decoder/DecodedParamRow.tsx b/src/transaction/decoder/DecodedParamRow.tsx index efd2917..78467d3 100644 --- a/src/transaction/decoder/DecodedParamRow.tsx +++ b/src/transaction/decoder/DecodedParamRow.tsx @@ -72,7 +72,10 @@ const DecodedParamRow: React.FC = ({ {paramType.baseType === "uint256" ? ( ) : paramType.baseType === "address" ? ( - + ) : paramType.baseType === "bool" ? ( ) : paramType.baseType === "bytes" ? ( diff --git a/src/transaction/decoder/InputDecoder.tsx b/src/transaction/decoder/InputDecoder.tsx index d516539..e3d041c 100644 --- a/src/transaction/decoder/InputDecoder.tsx +++ b/src/transaction/decoder/InputDecoder.tsx @@ -30,18 +30,17 @@ const InputDecoder: React.FC = ({ try { return toUtf8String(data); } catch (err) { - console.warn("Error while converting input data to string"); - console.warn(err); - return ""; + // Silently ignore on purpose + return undefined; } }, [data]); return ( - Decoded + Decoded Raw - UTF-8 + UTF-8 diff --git a/src/types.ts b/src/types.ts index 66ccebe..fde36bb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -107,4 +107,4 @@ export type TokenMeta = { decimals: number; }; -export type TokenMetas = Record; +export type TokenMetas = Record; diff --git a/src/use4Bytes.ts b/src/use4Bytes.ts index 9fe9252..3cf3f27 100644 --- a/src/use4Bytes.ts +++ b/src/use4Bytes.ts @@ -1,21 +1,97 @@ -import { useState, useEffect, useContext } from "react"; +import { useState, useEffect, useContext, useMemo } from "react"; +import { + Fragment, + Interface, + TransactionDescription, +} from "@ethersproject/abi"; import { RuntimeContext } from "./useRuntime"; import { fourBytesURL } from "./url"; +import { BigNumberish } from "@ethersproject/bignumber"; export type FourBytesEntry = { name: string; signature: string | undefined; }; +export type FourBytesMap = Record; + const simpleTransfer: FourBytesEntry = { - name: "Transfer", + name: "transfer", signature: undefined, }; const fullCache = new Map(); +export const extract4Bytes = (rawInput: string): string | null => { + if (rawInput.length < 10) { + return null; + } + return rawInput.slice(0, 10); +}; + export const rawInputTo4Bytes = (rawInput: string) => rawInput.slice(0, 10); +const fetch4Bytes = async ( + assetsURLPrefix: string, + fourBytes: string +): Promise => { + const signatureURL = fourBytesURL(assetsURLPrefix, fourBytes); + + try { + const res = await fetch(signatureURL); + if (!res.ok) { + console.error(`Signature does not exist in 4bytes DB: ${fourBytes}`); + return null; + } + + // Get only the first occurrence, for now ignore alternative param names + const sigs = await res.text(); + const sig = sigs.split(";")[0]; + const cut = sig.indexOf("("); + const method = sig.slice(0, cut); + + const entry: FourBytesEntry = { + name: method, + signature: sig, + }; + return entry; + } catch (err) { + console.error(`Couldn't fetch signature URL ${signatureURL}`, err); + return null; + } +}; + +export const useBatch4Bytes = ( + rawFourByteSigs: string[] | undefined +): FourBytesMap => { + const runtime = useContext(RuntimeContext); + const assetsURLPrefix = runtime.config?.assetsURLPrefix; + + const [fourBytesMap, setFourBytesMap] = useState({}); + useEffect(() => { + if (!rawFourByteSigs || !assetsURLPrefix) { + setFourBytesMap({}); + return; + } + + const loadSigs = async () => { + const promises = rawFourByteSigs.map((s) => + fetch4Bytes(assetsURLPrefix, s.slice(2)) + ); + const results = await Promise.all(promises); + + const _fourBytesMap: Record = {}; + for (let i = 0; i < rawFourByteSigs.length; i++) { + _fourBytesMap[rawFourByteSigs[i]] = results[i]; + } + setFourBytesMap(_fourBytesMap); + }; + loadSigs(); + }, [assetsURLPrefix, rawFourByteSigs]); + + return fourBytesMap; +}; + /** * Extract 4bytes DB info * @@ -47,35 +123,12 @@ export const use4Bytes = ( return; } - const signatureURL = fourBytesURL(assetsURLPrefix, fourBytes); - fetch(signatureURL) - .then(async (res) => { - if (!res.ok) { - console.error(`Signature does not exist in 4bytes DB: ${fourBytes}`); - fullCache.set(fourBytes, null); - setEntry(null); - return; - } - - // Get only the first occurrence, for now ignore alternative param names - const sigs = await res.text(); - const sig = sigs.split(";")[0]; - const cut = sig.indexOf("("); - let method = sig.slice(0, cut); - method = method.charAt(0).toUpperCase() + method.slice(1); - - const entry: FourBytesEntry = { - name: method, - signature: sig, - }; - setEntry(entry); - fullCache.set(fourBytes, entry); - }) - .catch((err) => { - console.error(`Couldn't fetch signature URL ${signatureURL}`, err); - setEntry(null); - fullCache.set(fourBytes, null); - }); + const loadSig = async () => { + const entry = await fetch4Bytes(assetsURLPrefix, fourBytes); + fullCache.set(fourBytes, entry); + setEntry(entry); + }; + loadSig(); }, [fourBytes, assetsURLPrefix]); if (rawFourBytes === "0x") { @@ -96,3 +149,29 @@ export const use4Bytes = ( fullCache.set(fourBytes, entry); return entry; }; + +export const useTransactionDescription = ( + fourBytesEntry: FourBytesEntry | null | undefined, + data: string | undefined, + value: BigNumberish | undefined +): TransactionDescription | null | undefined => { + const txDesc = useMemo(() => { + if (!fourBytesEntry) { + return fourBytesEntry; + } + if ( + !fourBytesEntry.signature || + data === undefined || + value === undefined + ) { + return undefined; + } + + const sig = fourBytesEntry?.signature; + const functionFragment = Fragment.fromString(`function ${sig}`); + const intf = new Interface([functionFragment]); + return intf.parseTransaction({ data, value }); + }, [fourBytesEntry, data, value]); + + return txDesc; +}; diff --git a/src/useErigonHooks.ts b/src/useErigonHooks.ts index 7322a0d..17d629f 100644 --- a/src/useErigonHooks.ts +++ b/src/useErigonHooks.ts @@ -1,10 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Block, BlockWithTransactions } from "@ethersproject/abstract-provider"; import { JsonRpcProvider } from "@ethersproject/providers"; import { getAddress } from "@ethersproject/address"; import { Contract } from "@ethersproject/contracts"; import { BigNumber } from "@ethersproject/bignumber"; import { arrayify, hexDataSlice, isHexString } from "@ethersproject/bytes"; +import { extract4Bytes } from "./use4Bytes"; import { getInternalOperations } from "./nodeFunctions"; import { TokenMetas, @@ -221,7 +222,7 @@ export const useTxData = ( // Extract token meta const tokenMetas: TokenMetas = {}; for (const t of tokenTransfers) { - if (tokenMetas[t.token]) { + if (tokenMetas[t.token] !== undefined) { continue; } const erc20Contract = new Contract(t.token, erc20, provider); @@ -237,6 +238,7 @@ export const useTxData = ( decimals, }; } catch (err) { + tokenMetas[t.token] = null; console.warn(`Couldn't get token ${t.token} metadata; ignoring`, err); } } @@ -308,3 +310,133 @@ export const useInternalOperations = ( return intTransfers; }; + +export type TraceEntry = { + type: string; + depth: number; + from: string; + to: string; + value: BigNumber; + input: string; +}; + +export type TraceGroup = TraceEntry & { + children: TraceGroup[] | null; +}; + +export const useTraceTransaction = ( + provider: JsonRpcProvider | undefined, + txHash: string +): TraceGroup[] | undefined => { + const [traceGroups, setTraceGroups] = useState(); + + useEffect(() => { + if (!provider) { + setTraceGroups(undefined); + return; + } + + const traceTx = async () => { + const results = await provider.send("ots_traceTransaction", [txHash]); + + // Implement better formatter + for (let i = 0; i < results.length; i++) { + results[i].from = provider.formatter.address(results[i].from); + results[i].to = provider.formatter.address(results[i].to); + results[i].value = + results[i].value === null + ? null + : provider.formatter.bigNumber(results[i].value); + } + + // Build trace tree + const buildTraceTree = ( + flatList: TraceEntry[], + depth: number = 0 + ): TraceGroup[] => { + const entries: TraceGroup[] = []; + + let children: TraceEntry[] | null = null; + for (let i = 0; i < flatList.length; i++) { + if (flatList[i].depth === depth) { + if (children !== null) { + const childrenTree = buildTraceTree(children, depth + 1); + const prev = entries.pop(); + if (prev) { + prev.children = childrenTree; + entries.push(prev); + } + } + + entries.push({ + ...flatList[i], + children: null, + }); + children = null; + } else { + if (children === null) { + children = []; + } + children.push(flatList[i]); + } + } + if (children !== null) { + const childrenTree = buildTraceTree(children, depth + 1); + const prev = entries.pop(); + if (prev) { + prev.children = childrenTree; + entries.push(prev); + } + } + + return entries; + }; + + const traceTree = buildTraceTree(results); + setTraceGroups(traceTree); + }; + traceTx(); + }, [provider, txHash]); + + return traceGroups; +}; + +/** + * Flatten a trace tree and extract and dedup 4byte function signatures + */ +export const useUniqueSignatures = (traces: TraceGroup[] | undefined) => { + const uniqueSignatures = useMemo(() => { + if (!traces) { + return undefined; + } + + const sigs = new Set(); + let nextTraces: TraceGroup[] = [...traces]; + while (nextTraces.length > 0) { + const traces = nextTraces; + nextTraces = []; + + for (const t of traces) { + if ( + t.type === "CALL" || + t.type === "DELEGATECALL" || + t.type === "STATICCALL" || + t.type === "CALLCODE" + ) { + const fourBytes = extract4Bytes(t.input); + if (fourBytes) { + sigs.add(fourBytes); + } + } + + if (t.children) { + nextTraces.push(...t.children); + } + } + } + + return [...sigs]; + }, [traces]); + + return uniqueSignatures; +}; diff --git a/src/useResolvedAddresses.ts b/src/useResolvedAddresses.ts index 1936da0..9eadfde 100644 --- a/src/useResolvedAddresses.ts +++ b/src/useResolvedAddresses.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { JsonRpcProvider } from "@ethersproject/providers"; import { ProcessedTransaction, TransactionData } from "./types"; import { batchPopulate, ResolvedAddresses } from "./api/address-resolver"; +import { TraceGroup } from "./useErigonHooks"; export type AddressCollector = () => string[]; @@ -61,6 +62,31 @@ export const transactionDataCollector = return Array.from(uniqueAddresses); }; +export const tracesCollector = + (traces: TraceGroup[] | undefined): AddressCollector => + () => { + if (traces === undefined) { + return []; + } + + const uniqueAddresses = new Set(); + let searchTraces = [...traces]; + while (searchTraces.length > 0) { + const nextSearch: TraceGroup[] = []; + + for (const g of searchTraces) { + uniqueAddresses.add(g.from); + uniqueAddresses.add(g.to); + if (g.children) { + nextSearch.push(...g.children); + } + } + + searchTraces = nextSearch; + } + return Array.from(uniqueAddresses); + }; + export const useResolvedAddresses = ( provider: JsonRpcProvider | undefined, addrCollector: AddressCollector diff --git a/src/useSelection.ts b/src/useSelection.ts index 03a2a92..8dc8f95 100644 --- a/src/useSelection.ts +++ b/src/useSelection.ts @@ -6,12 +6,14 @@ import { useContext, } from "react"; +export type SelectionType = "address" | "value" | "functionSig"; + export type Selection = { - type: "address" | "value"; + type: SelectionType; content: string; }; -type OptionalSelection = Selection | null; +export type OptionalSelection = Selection | null; export const useSelection = (): [ OptionalSelection, diff --git a/tsconfig.json b/tsconfig.json index 9d379a3..23c0098 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "downlevelIteration": true }, "include": ["src"] }