Merge branch 'feature/trace-tx' into develop
This commit is contained in:
commit
48686249f3
@ -15,7 +15,7 @@ import { Metadata } from "./useSourcify";
|
||||
|
||||
type TokenTransferItemProps = {
|
||||
t: TokenTransfer;
|
||||
tokenMeta?: TokenMeta | undefined;
|
||||
tokenMeta?: TokenMeta | null | undefined;
|
||||
resolvedAddresses: ResolvedAddresses | undefined;
|
||||
metadatas: Record<ChecksummedAddress, Metadata | null | undefined>;
|
||||
};
|
||||
|
@ -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})`}
|
||||
</NavTab>
|
||||
)}
|
||||
<NavTab href={`/tx/${txhash}/trace`}>Trace</NavTab>
|
||||
</Tab.List>
|
||||
</Tab.Group>
|
||||
<React.Suspense fallback={null}>
|
||||
@ -121,6 +128,12 @@ const Transaction: React.FC = () => {
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/tx/:txhash/trace" exact>
|
||||
<Trace
|
||||
txData={txData}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</SelectionContext.Provider>
|
||||
|
@ -11,11 +11,17 @@ export class ERCTokenResolver implements IAddressResolver<TokenMeta> {
|
||||
): Promise<TokenMeta | undefined> {
|
||||
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,
|
||||
|
@ -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<AddressHighlighterProps> = ({
|
||||
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]);
|
||||
}) => (
|
||||
<SelectionHighlighter
|
||||
myType="address"
|
||||
myContent={address}
|
||||
selector={addressSelector}
|
||||
>
|
||||
{children}
|
||||
</SelectionHighlighter>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border border-dashed rounded hover:bg-transparent hover:border-transparent px-1 truncate ${
|
||||
selection !== null &&
|
||||
selection.type === "address" &&
|
||||
selection.content === address
|
||||
? "border-orange-400 bg-yellow-100"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
onMouseEnter={select}
|
||||
onMouseLeave={deselect}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(AddressHighlighter);
|
||||
export default AddressHighlighter;
|
||||
|
27
src/components/AddressLink.tsx
Normal file
27
src/components/AddressLink.tsx
Normal file
@ -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<AddressLinkProps> = ({
|
||||
address,
|
||||
text,
|
||||
dontOverrideColors,
|
||||
}) => (
|
||||
<NavLink
|
||||
className={`${
|
||||
dontOverrideColors ? "" : "text-link-blue hover:text-link-blue-hover"
|
||||
} font-address truncate`}
|
||||
to={`/address/${address}`}
|
||||
>
|
||||
<span className="truncate" title={text ?? address}>
|
||||
{text ?? address}
|
||||
</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export default AddressLink;
|
34
src/components/ENSNameLink.tsx
Normal file
34
src/components/ENSNameLink.tsx
Normal file
@ -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<ENSNameLinkProps> = ({
|
||||
name,
|
||||
address,
|
||||
dontOverrideColors,
|
||||
}) => (
|
||||
<NavLink
|
||||
className={`flex items-baseline space-x-1 font-sans ${
|
||||
dontOverrideColors ? "" : "text-link-blue hover:text-link-blue-hover"
|
||||
} truncate`}
|
||||
to={`/address/${name}`}
|
||||
title={`${name}: ${address}`}
|
||||
>
|
||||
<img
|
||||
className="self-center"
|
||||
src={ENSLogo}
|
||||
alt="ENS Logo"
|
||||
width={12}
|
||||
height={12}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export default ENSNameLink;
|
@ -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<ModeTabProps> = ({ disabled, children }) => (
|
||||
<Tab
|
||||
className={({ selected }) =>
|
||||
`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}
|
||||
</Tab>
|
||||
|
78
src/components/SelectionHighlighter.tsx
Normal file
78
src/components/SelectionHighlighter.tsx
Normal file
@ -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<SelectionHighlighterProps> = ({
|
||||
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 (
|
||||
<HighlighterBox
|
||||
selected={selector(selection, myContent)}
|
||||
select={select}
|
||||
deselect={deselect}
|
||||
>
|
||||
{children}
|
||||
</HighlighterBox>
|
||||
);
|
||||
};
|
||||
|
||||
type HighlighterBoxProps = {
|
||||
selected: boolean;
|
||||
select: () => void;
|
||||
deselect: () => void;
|
||||
};
|
||||
|
||||
const HighlighterBox: React.FC<HighlighterBoxProps> = React.memo(
|
||||
({ selected, select, deselect, children }) => (
|
||||
<div
|
||||
className={`border border-dashed rounded hover:bg-transparent hover:border-transparent px-1 truncate ${
|
||||
selected ? "border-orange-400 bg-yellow-100" : "border-transparent"
|
||||
}`}
|
||||
onMouseEnter={select}
|
||||
onMouseLeave={deselect}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export default SelectionHighlighter;
|
@ -6,6 +6,7 @@ type ValueHighlighterProps = React.PropsWithChildren<{
|
||||
value: BigNumber;
|
||||
}>;
|
||||
|
||||
// TODO: replace all occurences with SelectionHighlighter and remove this component
|
||||
const ValueHighlighter: React.FC<ValueHighlighterProps> = ({
|
||||
value,
|
||||
children,
|
||||
|
@ -1,3 +1,3 @@
|
||||
export const MIN_API_LEVEL = 2;
|
||||
export const MIN_API_LEVEL = 3;
|
||||
|
||||
export const PAGE_SIZE = 25;
|
||||
|
@ -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<DetailsProps> = ({
|
||||
|
||||
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;
|
||||
|
34
src/transaction/FunctionSignature.tsx
Normal file
34
src/transaction/FunctionSignature.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import SelectionHighlighter, {
|
||||
functionSigSelector,
|
||||
} from "../components/SelectionHighlighter";
|
||||
|
||||
type FunctionSignatureProps = {
|
||||
callType: string;
|
||||
sig: string;
|
||||
};
|
||||
|
||||
const FunctionSignature: React.FC<FunctionSignatureProps> = ({
|
||||
callType,
|
||||
sig,
|
||||
}) => (
|
||||
<SelectionHighlighter
|
||||
myType="functionSig"
|
||||
myContent={sig}
|
||||
selector={functionSigSelector}
|
||||
>
|
||||
<span
|
||||
className={`font-bold ${
|
||||
callType === "STATICCALL"
|
||||
? "text-red-700"
|
||||
: callType === "DELEGATECALL" || callType === "CALLCODE"
|
||||
? "text-gray-400"
|
||||
: "text-blue-900"
|
||||
}`}
|
||||
>
|
||||
{sig}
|
||||
</span>
|
||||
</SelectionHighlighter>
|
||||
);
|
||||
|
||||
export default FunctionSignature;
|
69
src/transaction/Trace.tsx
Normal file
69
src/transaction/Trace.tsx
Normal file
@ -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<TraceProps> = ({ 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 (
|
||||
<ContentFrame tabs>
|
||||
<div className="mt-4 mb-5 space-y-3 font-code text-sm flex flex-col items-start overflow-x-auto">
|
||||
{traces ? (
|
||||
<>
|
||||
<div className="border hover:border-gray-500 rounded px-1 py-0.5">
|
||||
<TransactionAddress
|
||||
address={txData.from}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-5 space-y-3 self-stretch">
|
||||
{traces.map((t, i, a) => (
|
||||
<TraceItem
|
||||
key={i}
|
||||
t={t}
|
||||
last={i === a.length - 1}
|
||||
fourBytesMap={sigMap}
|
||||
resolvedAddresses={mergedResolvedAddresses}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="border hover:border-gray-500 rounded px-1 py-1 w-96 h-7">
|
||||
<div className="animate-pulse w-full h-full rounded bg-gray-200"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ContentFrame>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Trace);
|
113
src/transaction/TraceInput.tsx
Normal file
113
src/transaction/TraceInput.tsx
Normal file
@ -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<string, FourBytesEntry | null | undefined>;
|
||||
resolvedAddresses: ResolvedAddresses | undefined;
|
||||
};
|
||||
|
||||
const TraceInput: React.FC<TraceInputProps> = ({
|
||||
t,
|
||||
fourBytesMap,
|
||||
resolvedAddresses,
|
||||
}) => {
|
||||
const raw4Bytes = extract4Bytes(t.input);
|
||||
const fourBytes = raw4Bytes !== null ? fourBytesMap[raw4Bytes] : null;
|
||||
const sigText =
|
||||
raw4Bytes === null ? "<fallback>" : fourBytes?.name ?? raw4Bytes;
|
||||
const hasParams = t.input.length > 10;
|
||||
|
||||
const fourBytesTxDesc = useTransactionDescription(
|
||||
fourBytes,
|
||||
t.input,
|
||||
t.value
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ml-5 border hover:border-gray-500 rounded px-1 py-0.5 ${
|
||||
expanded ? "w-full" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-xs text-gray-400 lowercase">{t.type}</span>
|
||||
{t.type === "SELFDESTRUCT" ? (
|
||||
<span className="pl-2 text-red-800" title="Self destruct">
|
||||
<FontAwesomeIcon icon={faBomb} size="1x" />
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span>
|
||||
<TransactionAddress
|
||||
address={t.to}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
</span>
|
||||
{t.type !== "CREATE" && t.type !== "CREATE2" && (
|
||||
<>
|
||||
<span>.</span>
|
||||
<FunctionSignature callType={t.type} sig={sigText} />
|
||||
{t.value && !t.value.isZero() && (
|
||||
<span className="text-red-700 whitespace-nowrap">
|
||||
{"{"}value: <FormattedBalance value={t.value} /> ETH{"}"}
|
||||
</span>
|
||||
)}
|
||||
<span className="whitespace-nowrap">
|
||||
(
|
||||
{hasParams && (
|
||||
<Switch
|
||||
className="text-xs"
|
||||
checked={expanded}
|
||||
onChange={setExpanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<span className="text-gray-400">[-]</span>
|
||||
) : (
|
||||
<>[...]</>
|
||||
)}
|
||||
</Switch>
|
||||
)}
|
||||
{(!hasParams || !expanded) && <>)</>}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasParams && expanded && (
|
||||
<>
|
||||
<div className="ml-5 mr-1 my-2">
|
||||
<InputDecoder
|
||||
fourBytes={raw4Bytes ?? "0x"}
|
||||
resolvedTxDesc={fourBytesTxDesc}
|
||||
hasParamNames={false}
|
||||
data={t.input}
|
||||
userMethod={undefined}
|
||||
devMethod={undefined}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
</div>
|
||||
<div>)</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TraceInput;
|
92
src/transaction/TraceItem.tsx
Normal file
92
src/transaction/TraceItem.tsx
Normal file
@ -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<string, FourBytesEntry | null | undefined>;
|
||||
resolvedAddresses: ResolvedAddresses | undefined;
|
||||
};
|
||||
|
||||
const TraceItem: React.FC<TraceItemProps> = ({
|
||||
t,
|
||||
last,
|
||||
fourBytesMap,
|
||||
resolvedAddresses,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState<boolean>(true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex relative">
|
||||
<div className="absolute border-l border-b w-5 h-6 transform -translate-y-3"></div>
|
||||
{!last && (
|
||||
<div className="absolute left-0 border-l w-5 h-full transform translate-y-3"></div>
|
||||
)}
|
||||
{t.children && (
|
||||
<Switch
|
||||
className="absolute left-0 bg-white transform -translate-x-1/2 text-gray-500"
|
||||
checked={expanded}
|
||||
onChange={setExpanded}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={expanded ? faMinusSquare : faPlusSquare}
|
||||
size="1x"
|
||||
/>
|
||||
</Switch>
|
||||
)}
|
||||
<TraceInput
|
||||
t={t}
|
||||
fourBytesMap={fourBytesMap}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
</div>
|
||||
{t.children && (
|
||||
<div
|
||||
className={`pl-10 ${last ? "" : "border-l"} space-y-3 ${
|
||||
expanded ? "" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<TraceChildren
|
||||
c={t.children}
|
||||
fourBytesMap={fourBytesMap}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type TraceChildrenProps = {
|
||||
c: TraceGroup[];
|
||||
fourBytesMap: Record<string, FourBytesEntry | null | undefined>;
|
||||
resolvedAddresses: ResolvedAddresses | undefined;
|
||||
};
|
||||
|
||||
const TraceChildren: React.FC<TraceChildrenProps> = React.memo(
|
||||
({ c, fourBytesMap, resolvedAddresses }) => {
|
||||
return (
|
||||
<>
|
||||
{c.map((tc, i, a) => (
|
||||
<TraceItem
|
||||
key={i}
|
||||
t={tc}
|
||||
last={i === a.length - 1}
|
||||
fourBytesMap={fourBytesMap}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TraceItem;
|
@ -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<AddressDecoderProps> = ({
|
||||
resolvedAddresses,
|
||||
}) => (
|
||||
<div className="flex items-baseline space-x-2 -ml-1 mr-3">
|
||||
<TransactionAddress
|
||||
address={r.toString()}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
<Copy value={r.toString()} />
|
||||
<TransactionAddress address={r} resolvedAddresses={resolvedAddresses} />
|
||||
<Copy value={r} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default React.memo(AddressDecoder);
|
||||
export default AddressDecoder;
|
||||
|
@ -72,7 +72,10 @@ const DecodedParamRow: React.FC<DecodedParamRowProps> = ({
|
||||
{paramType.baseType === "uint256" ? (
|
||||
<Uint256Decoder r={r} />
|
||||
) : paramType.baseType === "address" ? (
|
||||
<AddressDecoder r={r} resolvedAddresses={resolvedAddresses} />
|
||||
<AddressDecoder
|
||||
r={r.toString()}
|
||||
resolvedAddresses={resolvedAddresses}
|
||||
/>
|
||||
) : paramType.baseType === "bool" ? (
|
||||
<BooleanDecoder r={r} />
|
||||
) : paramType.baseType === "bytes" ? (
|
||||
|
@ -30,18 +30,17 @@ const InputDecoder: React.FC<InputDecoderProps> = ({
|
||||
try {
|
||||
return toUtf8String(data);
|
||||
} catch (err) {
|
||||
console.warn("Error while converting input data to string");
|
||||
console.warn(err);
|
||||
return "<can't decode>";
|
||||
// Silently ignore on purpose
|
||||
return undefined;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Tab.Group>
|
||||
<Tab.List className="flex space-x-1 mb-1">
|
||||
<ModeTab>Decoded</ModeTab>
|
||||
<ModeTab disabled={!resolvedTxDesc}>Decoded</ModeTab>
|
||||
<ModeTab>Raw</ModeTab>
|
||||
<ModeTab>UTF-8</ModeTab>
|
||||
<ModeTab disabled={utfInput === undefined}>UTF-8</ModeTab>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel>
|
||||
|
@ -107,4 +107,4 @@ export type TokenMeta = {
|
||||
decimals: number;
|
||||
};
|
||||
|
||||
export type TokenMetas = Record<string, TokenMeta>;
|
||||
export type TokenMetas = Record<string, TokenMeta | null | undefined>;
|
||||
|
141
src/use4Bytes.ts
141
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<string, FourBytesEntry | null | undefined>;
|
||||
|
||||
const simpleTransfer: FourBytesEntry = {
|
||||
name: "Transfer",
|
||||
name: "transfer",
|
||||
signature: undefined,
|
||||
};
|
||||
|
||||
const fullCache = new Map<string, FourBytesEntry | null>();
|
||||
|
||||
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<FourBytesEntry | null> => {
|
||||
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<FourBytesMap>({});
|
||||
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<string, FourBytesEntry | null> = {};
|
||||
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;
|
||||
};
|
||||
|
@ -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<TraceGroup[] | undefined>();
|
||||
|
||||
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<string>();
|
||||
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;
|
||||
};
|
||||
|
@ -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<string>();
|
||||
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
|
||||
|
@ -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,
|
||||
|
@ -14,7 +14,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user