diff --git a/src/AddressTransactions.tsx b/src/AddressTransactions.tsx index 45a07ca..e3a23a0 100644 --- a/src/AddressTransactions.tsx +++ b/src/AddressTransactions.tsx @@ -31,8 +31,8 @@ import { useENSCache } from "./useReverseCache"; import { useFeeToggler } from "./search/useFeeToggler"; import { SelectionContext, useSelection } from "./useSelection"; import { useMultipleETHUSDOracle } from "./usePriceOracle"; +import { useAppConfigContext } from "./useAppConfig"; import { useSourcify } from "./useSourcify"; -import { SourcifySource } from "./url"; type BlockParams = { addressOrName: string; @@ -180,9 +180,7 @@ const AddressTransactions: React.FC = () => { const [feeDisplay, feeDisplayToggler] = useFeeToggler(); const selectionCtx = useSelection(); - const [sourcifySource, setSourcifySource] = useState( - SourcifySource.IPFS_IPNS - ); + const { sourcifySource } = useAppConfigContext(); const rawMetadata = useSourcify( checksummedAddress, provider?.network.chainId, @@ -316,8 +314,6 @@ const AddressTransactions: React.FC = () => { diff --git a/src/App.tsx b/src/App.tsx index 4f1333d..648c58f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { Suspense } from "react"; +import React, { Suspense, useMemo, useState } from "react"; import { BrowserRouter as Router, Switch, Route } from "react-router-dom"; import WarningHeader from "./WarningHeader"; import Home from "./Home"; @@ -9,6 +9,8 @@ import London from "./special/london/London"; import Footer from "./Footer"; import { ConnectionStatus } from "./types"; import { RuntimeContext, useRuntime } from "./useRuntime"; +import { AppConfig, AppConfigContext } from "./useAppConfig"; +import { SourcifySource } from "./url"; const Block = React.lazy(() => import("./Block")); const BlockTransactions = React.lazy(() => import("./BlockTransactions")); @@ -17,6 +19,15 @@ const Transaction = React.lazy(() => import("./Transaction")); const App = () => { const runtime = useRuntime(); + const [sourcifySource, setSourcifySource] = useState( + SourcifySource.IPFS_IPNS + ); + const appConfig = useMemo((): AppConfig => { + return { + sourcifySource, + setSourcifySource, + }; + }, [sourcifySource, setSourcifySource]); return ( LOADING}> @@ -41,21 +52,23 @@ const App = () => { -
- - <Route path="/block/:blockNumberOrHash" exact> - <Block /> - </Route> - <Route path="/block/:blockNumber/txs" exact> - <BlockTransactions /> - </Route> - <Route path="/tx/:txhash"> - <Transaction /> - </Route> - <Route path="/address/:addressOrName/:direction?"> - <AddressTransactions /> - </Route> - </div> + <AppConfigContext.Provider value={appConfig}> + <div className="mb-auto"> + <Title /> + <Route path="/block/:blockNumberOrHash" exact> + <Block /> + </Route> + <Route path="/block/:blockNumber/txs" exact> + <BlockTransactions /> + </Route> + <Route path="/tx/:txhash"> + <Transaction /> + </Route> + <Route path="/address/:addressOrName/:direction?"> + <AddressTransactions /> + </Route> + </div> + </AppConfigContext.Provider> </Route> </Switch> </Router> diff --git a/src/SourcifyMenu.tsx b/src/SourcifyMenu.tsx new file mode 100644 index 0000000..36f43a5 --- /dev/null +++ b/src/SourcifyMenu.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { Menu } from "@headlessui/react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBars } from "@fortawesome/free-solid-svg-icons/faBars"; +import { SourcifySource } from "./url"; +import { useAppConfigContext } from "./useAppConfig"; + +const SourcifyMenu: React.FC = () => { + const { sourcifySource, setSourcifySource } = useAppConfigContext(); + + return ( + <Menu> + <div className="relative self-stretch"> + <Menu.Button className="w-full h-full flex justify-center items-center space-x-2 text-sm border rounded px-2 py-1"> + <FontAwesomeIcon icon={faBars} size="1x" /> + </Menu.Button> + <Menu.Items className="absolute right-0 mt-1 border p-1 rounded-b bg-white flex flex-col text-sm min-w-max"> + <div className="px-2 py-1 text-xs border-b border-gray-300"> + Sourcify Datasource + </div> + <SourcifyMenuItem + checked={sourcifySource === SourcifySource.IPFS_IPNS} + onClick={() => setSourcifySource(SourcifySource.IPFS_IPNS)} + > + Resolve IPNS + </SourcifyMenuItem> + <SourcifyMenuItem + checked={sourcifySource === SourcifySource.CENTRAL_SERVER} + onClick={() => setSourcifySource(SourcifySource.CENTRAL_SERVER)} + > + Sourcify Servers + </SourcifyMenuItem> + <SourcifyMenuItem + checked={sourcifySource === SourcifySource.CUSTOM_SNAPSHOT_SERVER} + onClick={() => + setSourcifySource(SourcifySource.CUSTOM_SNAPSHOT_SERVER) + } + > + Local Snapshot + </SourcifyMenuItem> + </Menu.Items> + </div> + </Menu> + ); +}; + +type SourcifyMenuItemProps = { + checked?: boolean; + onClick: () => void; +}; + +const SourcifyMenuItem: React.FC<SourcifyMenuItemProps> = ({ + checked = false, + onClick, + children, +}) => ( + <Menu.Item> + {({ active }) => ( + <button + className={`text-sm text-left px-2 py-1 ${ + active ? "border-orange-200 text-gray-500" : "text-gray-400" + } transition-transform transition-colors duration-75 ${ + checked ? "text-gray-900" : "" + }`} + onClick={onClick} + > + {children} + </button> + )} + </Menu.Item> +); + +export default React.memo(SourcifyMenu); diff --git a/src/Title.tsx b/src/Title.tsx index 25b408d..e4c5f47 100644 --- a/src/Title.tsx +++ b/src/Title.tsx @@ -5,6 +5,7 @@ import { faQrcode } from "@fortawesome/free-solid-svg-icons/faQrcode"; import useKeyboardShortcut from "use-keyboard-shortcut"; import PriceBox from "./PriceBox"; import CameraScanner from "./search/CameraScanner"; +import SourcifyMenu from "./SourcifyMenu"; import { RuntimeContext } from "./useRuntime"; const Title: React.FC = () => { @@ -82,6 +83,7 @@ const Title: React.FC = () => { Search </button> </form> + <SourcifyMenu /> </div> </div> </> diff --git a/src/TokenTransferItem.tsx b/src/TokenTransferItem.tsx index a093b7f..c9f40a2 100644 --- a/src/TokenTransferItem.tsx +++ b/src/TokenTransferItem.tsx @@ -18,6 +18,7 @@ type TokenTransferItemProps = { tokenMetas: TokenMetas; }; +// TODO: handle partial const TokenTransferItem: React.FC<TokenTransferItemProps> = ({ t, txData, diff --git a/src/Transaction.tsx b/src/Transaction.tsx index e204633..29c7208 100644 --- a/src/Transaction.tsx +++ b/src/Transaction.tsx @@ -11,6 +11,8 @@ import { RuntimeContext } from "./useRuntime"; import { SelectionContext, useSelection } from "./useSelection"; import { useInternalOperations, useTxData } from "./useErigonHooks"; import { useETHUSDOracle } from "./usePriceOracle"; +import { useAppConfigContext } from "./useAppConfig"; +import { useSourcify, useTransactionDescription } from "./useSourcify"; type TransactionParams = { txhash: string; @@ -44,6 +46,14 @@ const Transaction: React.FC = () => { txData?.confirmedData?.blockNumber ); + const { sourcifySource } = useAppConfigContext(); + const metadata = useSourcify( + txData?.to, + provider?.network.chainId, + sourcifySource + ); + const txDesc = useTransactionDescription(metadata, txData); + return ( <StandardFrame> <StandardSubtitle>Transaction Details</StandardSubtitle> @@ -71,13 +81,14 @@ const Transaction: React.FC = () => { <Route path="/tx/:txhash/" exact> <Details txData={txData} + txDesc={txDesc} internalOps={internalOps} sendsEthToMiner={sendsEthToMiner} ethUSDPrice={blockETHUSDPrice} /> </Route> <Route path="/tx/:txhash/logs/" exact> - <Logs txData={txData} /> + <Logs txData={txData} metadata={metadata} /> </Route> </Switch> </SelectionContext.Provider> diff --git a/src/address/Contract.tsx b/src/address/Contract.tsx index eece5fd..7ddfe56 100644 --- a/src/address/Contract.tsx +++ b/src/address/Contract.tsx @@ -3,9 +3,9 @@ import { Light as SyntaxHighlighter } from "react-syntax-highlighter"; import hljs from "highlight.js"; import docco from "react-syntax-highlighter/dist/esm/styles/hljs/docco"; import { useContract } from "../useSourcify"; -import { SourcifySource } from "../url"; import hljsDefineSolidity from "highlightjs-solidity"; +import { useAppConfigContext } from "../useAppConfig"; hljsDefineSolidity(hljs); type ContractProps = { @@ -13,7 +13,6 @@ type ContractProps = { networkId: number; filename: string; source: any; - sourcifySource: SourcifySource; }; const Contract: React.FC<ContractProps> = ({ @@ -21,8 +20,8 @@ const Contract: React.FC<ContractProps> = ({ networkId, filename, source, - sourcifySource, }) => { + const { sourcifySource } = useAppConfigContext(); const content = useContract( checksummedAddress, networkId, diff --git a/src/address/Contracts.tsx b/src/address/Contracts.tsx index 4d322c3..0745d73 100644 --- a/src/address/Contracts.tsx +++ b/src/address/Contracts.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useContext, Fragment } from "react"; import { commify } from "@ethersproject/units"; -import { Menu, RadioGroup } from "@headlessui/react"; +import { Menu } from "@headlessui/react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown"; import ContentFrame from "../ContentFrame"; @@ -11,21 +11,16 @@ import Contract from "./Contract"; import { RuntimeContext } from "../useRuntime"; import { Metadata } from "../useSourcify"; import ExternalLink from "../components/ExternalLink"; -import { openInRemixURL, SourcifySource } from "../url"; -import RadioButton from "./RadioButton"; +import { openInRemixURL } from "../url"; type ContractsProps = { checksummedAddress: string; rawMetadata: Metadata | null | undefined; - sourcifySource: SourcifySource; - setSourcifySource: (sourcifySource: SourcifySource) => void; }; const Contracts: React.FC<ContractsProps> = ({ checksummedAddress, rawMetadata, - sourcifySource, - setSourcifySource, }) => { const { provider } = useContext(RuntimeContext); @@ -39,21 +34,6 @@ const Contracts: React.FC<ContractsProps> = ({ return ( <ContentFrame tabs> - <InfoRow title="Sourcify integration"> - <RadioGroup value={sourcifySource} onChange={setSourcifySource}> - <div className="flex space-x-2"> - <RadioButton value={SourcifySource.IPFS_IPNS}> - Resolve IPNS @localhost:8080 gateway - </RadioButton> - <RadioButton value={SourcifySource.CENTRAL_SERVER}> - Sourcify Servers - </RadioButton> - <RadioButton value={SourcifySource.CUSTOM_SNAPSHOT_SERVER}> - Local Snapshot @localhost:3006 - </RadioButton> - </div> - </RadioGroup> - </InfoRow> {rawMetadata && ( <> <InfoRow title="Language"> @@ -145,7 +125,6 @@ const Contracts: React.FC<ContractsProps> = ({ networkId={provider!.network.chainId} filename={selected} source={rawMetadata.sources[selected]} - sourcifySource={sourcifySource} /> )} </div> diff --git a/src/address/RadioButton.tsx b/src/address/RadioButton.tsx deleted file mode 100644 index e93fad3..0000000 --- a/src/address/RadioButton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from "react"; -import { RadioGroup } from "@headlessui/react"; -import { SourcifySource } from "../url"; - -type RadioButtonProps = { - value: SourcifySource; -}; - -const RadioButton: React.FC<RadioButtonProps> = ({ value, children }) => ( - <RadioGroup.Option - className={({ checked }) => - `border rounded px-2 py-1 cursor-pointer ${ - checked - ? "bg-blue-400 hover:bg-blue-500 text-white" - : "hover:bg-gray-200" - }` - } - value={value} - > - {children} - </RadioGroup.Option> -); - -export default RadioButton; diff --git a/src/components/ModeTab.tsx b/src/components/ModeTab.tsx new file mode 100644 index 0000000..14a33fd --- /dev/null +++ b/src/components/ModeTab.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { Tab } from "@headlessui/react"; + +const ModeTab: React.FC = ({ 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" : "" + }` + } + > + {children} + </Tab> +); + +export default ModeTab; diff --git a/src/transaction/DecodedLogSignature.tsx b/src/transaction/DecodedLogSignature.tsx new file mode 100644 index 0000000..42cfbe9 --- /dev/null +++ b/src/transaction/DecodedLogSignature.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { EventFragment } from "@ethersproject/abi"; + +type DecodedLogSignatureProps = { + event: EventFragment; +}; + +const DecodedLogSignature: React.FC<DecodedLogSignatureProps> = ({ event }) => { + return ( + <span> + <span className="text-blue-900 font-bold">{event.name}</span>( + {event.inputs.map((input, i) => ( + <span key={i}> + {i > 0 ? ", " : ""} + <span>{input.format("full")}</span> + </span> + ))} + ){event.anonymous ? " anonymous" : ""} + </span> + ); +}; + +export default React.memo(DecodedLogSignature); diff --git a/src/transaction/DecodedParamRow.tsx b/src/transaction/DecodedParamRow.tsx new file mode 100644 index 0000000..9d9bcf8 --- /dev/null +++ b/src/transaction/DecodedParamRow.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import AddressHighlighter from "../components/AddressHighlighter"; +import DecoratedAddressLink from "../components/DecoratedAddressLink"; +import Copy from "../components/Copy"; +import { ParamType } from "@ethersproject/abi"; +import { TransactionData } from "../types"; + +type DecodedParamRowProps = { + prefix?: string; + i?: number | undefined; + r: any; + paramType: ParamType; + txData: TransactionData; +}; + +const DecodedParamRow: React.FC<DecodedParamRowProps> = ({ + prefix, + i, + r, + paramType, + txData, +}) => { + return ( + <> + <tr className="grid grid-cols-12 gap-x-2 py-2 hover:bg-gray-100"> + <td className="col-span-3 pl-1"> + {prefix && <span className="text-gray-300">{prefix}</span>} + {paramType.name}{" "} + {i !== undefined && ( + <span className="text-gray-400 text-xs">({i})</span> + )} + </td> + <td className="col-span-1 text-gray-500">{paramType.type}</td> + <td className="col-span-8 pr-1 font-code break-all"> + {paramType.baseType === "address" ? ( + <div className="flex items-baseline space-x-2 -ml-1 mr-3"> + <AddressHighlighter address={r.toString()}> + <DecoratedAddressLink + address={r.toString()} + miner={r.toString() === txData.confirmedData?.miner} + txFrom={r.toString() === txData.from} + txTo={r.toString() === txData.to} + /> + </AddressHighlighter> + <Copy value={r.toString()} /> + </div> + ) : paramType.baseType === "bool" ? ( + <span className={`${r ? "text-green-700" : "text-red-700"}`}> + {r.toString()} + </span> + ) : paramType.baseType === "bytes" ? ( + <span> + {r.toString()}{" "} + <span className="font-sans text-xs text-gray-400"> + {r.toString().length / 2 - 1}{" "} + {r.toString().length / 2 - 1 === 1 ? "byte" : "bytes"} + </span> + </span> + ) : paramType.baseType === "tuple" ? ( + <></> + ) : ( + r.toString() + )} + </td> + </tr> + {paramType.baseType === "tuple" && + r.map((e: any, idx: number) => ( + <DecodedParamRow key={idx} + prefix={paramType.name + "."} + r={e} + paramType={paramType.components[idx]} + txData={txData} + /> + ))} + </> + ); +}; + +export default React.memo(DecodedParamRow); diff --git a/src/transaction/DecodedParamsTable.tsx b/src/transaction/DecodedParamsTable.tsx new file mode 100644 index 0000000..ff439d8 --- /dev/null +++ b/src/transaction/DecodedParamsTable.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { ParamType, Result } from "@ethersproject/abi"; +import DecodedParamRow from "./DecodedParamRow"; +import { TransactionData } from "../types"; + +type DecodedParamsTableProps = { + args: Result; + paramTypes: ParamType[]; + txData: TransactionData; +}; + +const DecodedParamsTable: React.FC<DecodedParamsTableProps> = ({ + args, + paramTypes, + txData, +}) => ( + <table className="border rounded w-full"> + <thead> + <tr className="grid grid-cols-12 text-left gap-x-2 py-2 bg-gray-100"> + <th className="col-span-3 pl-1"> + name <span className="text-gray-400 text-xs">(index)</span> + </th> + <th className="col-span-1">type</th> + <th className="col-span-8 pr-1">value</th> + </tr> + </thead> + <tbody className="divide-y"> + {args.map((r, i) => ( + <DecodedParamRow + key={i} + i={i} + r={r} + paramType={paramTypes[i]} + txData={txData} + /> + ))} + </tbody> + </table> +); + +export default React.memo(DecodedParamsTable); diff --git a/src/transaction/Details.tsx b/src/transaction/Details.tsx index e5ba19a..bea1587 100644 --- a/src/transaction/Details.tsx +++ b/src/transaction/Details.tsx @@ -1,6 +1,8 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo } from "react"; +import { TransactionDescription } from "@ethersproject/abi"; import { BigNumber } from "@ethersproject/bignumber"; import { toUtf8String } from "@ethersproject/strings"; +import { Tab } from "@headlessui/react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCheckCircle } from "@fortawesome/free-solid-svg-icons/faCheckCircle"; import { faCube } from "@fortawesome/free-solid-svg-icons/faCube"; @@ -28,9 +30,12 @@ import PercentageBar from "../components/PercentageBar"; import ExternalLink from "../components/ExternalLink"; import RelativePosition from "../components/RelativePosition"; import PercentagePosition from "../components/PercentagePosition"; +import ModeTab from "../components/ModeTab"; +import DecodedParamsTable from "./DecodedParamsTable"; type DetailsProps = { txData: TransactionData; + txDesc: TransactionDescription | null | undefined; internalOps?: InternalOperation[]; sendsEthToMiner: boolean; ethUSDPrice: BigNumber | undefined; @@ -38,6 +43,7 @@ type DetailsProps = { const Details: React.FC<DetailsProps> = ({ txData, + txDesc, internalOps, sendsEthToMiner, ethUSDPrice, @@ -45,7 +51,6 @@ const Details: React.FC<DetailsProps> = ({ const hasEIP1559 = txData.confirmedData?.blockBaseFeePerGas !== undefined && txData.confirmedData?.blockBaseFeePerGas !== null; - const [inputMode, setInputMode] = useState<number>(0); const utfInput = useMemo(() => { try { @@ -309,31 +314,42 @@ const Details: React.FC<DetailsProps> = ({ </> )} <InfoRow title="Input Data"> - <div className="space-y-1"> - <div className="flex space-x-1"> - <button - className={`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 ${ - inputMode === 0 ? "border-blue-300" : "" - }`} - onClick={() => setInputMode(0)} - > - Raw - </button> - <button - className={`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 ${ - inputMode === 1 ? "border-blue-300" : "" - }`} - onClick={() => setInputMode(1)} - > - UTF-8 - </button> - </div> - <textarea - className="w-full h-40 bg-gray-50 text-gray-500 font-mono focus:outline-none border rounded p-2" - value={inputMode === 0 ? txData.data : utfInput} - readOnly - /> - </div> + <Tab.Group> + <Tab.List className="flex space-x-1 mb-1"> + <ModeTab>Decoded</ModeTab> + <ModeTab>Raw</ModeTab> + <ModeTab>UTF-8</ModeTab> + </Tab.List> + <Tab.Panels> + <Tab.Panel> + {txDesc === undefined ? ( + <>Waiting for data...</> + ) : txDesc === null ? ( + <>No decoded data</> + ) : ( + <DecodedParamsTable + args={txDesc.args} + paramTypes={txDesc.functionFragment.inputs} + txData={txData} + /> + )} + </Tab.Panel> + <Tab.Panel> + <textarea + className="w-full h-40 bg-gray-50 text-gray-500 font-mono focus:outline-none border rounded p-2" + value={txData.data} + readOnly + /> + </Tab.Panel> + <Tab.Panel> + <textarea + className="w-full h-40 bg-gray-50 text-gray-500 font-mono focus:outline-none border rounded p-2" + value={utfInput} + readOnly + /> + </Tab.Panel> + </Tab.Panels> + </Tab.Group> </InfoRow> </ContentFrame> ); diff --git a/src/transaction/LogEntry.tsx b/src/transaction/LogEntry.tsx new file mode 100644 index 0000000..1d4e85f --- /dev/null +++ b/src/transaction/LogEntry.tsx @@ -0,0 +1,116 @@ +import React, { Fragment } from "react"; +import { Log } from "@ethersproject/abstract-provider"; +import { LogDescription } from "@ethersproject/abi"; +import { Tab } from "@headlessui/react"; +import AddressHighlighter from "../components/AddressHighlighter"; +import DecoratedAddressLink from "../components/DecoratedAddressLink"; +import Copy from "../components/Copy"; +import ModeTab from "../components/ModeTab"; +import DecodedParamsTable from "./DecodedParamsTable"; +import DecodedLogSignature from "./DecodedLogSignature"; +import { TransactionData } from "../types"; + +type LogEntryProps = { + txData: TransactionData; + log: Log; + logDesc: LogDescription | null | undefined; +}; + +const LogEntry: React.FC<LogEntryProps> = ({ txData, log, logDesc }) => ( + <div className="flex space-x-10 py-5"> + <div> + <span className="rounded-full w-12 h-12 flex items-center justify-center bg-green-50 text-green-500"> + {log.logIndex} + </span> + </div> + <div className="w-full space-y-2"> + <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> + <div className="font-bold text-right">Address</div> + <div className="col-span-11 mr-auto"> + <div className="flex items-baseline space-x-2 -ml-1 mr-3"> + <AddressHighlighter address={log.address}> + <DecoratedAddressLink + address={log.address} + miner={log.address === txData.confirmedData?.miner} + txFrom={log.address === txData.from} + txTo={log.address === txData.to} + /> + </AddressHighlighter> + <Copy value={log.address} /> + </div> + </div> + </div> + <Tab.Group> + <Tab.List className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> + <div className="text-right">Parameters</div> + <div className="col-span-11 flex space-x-1 mb-1"> + <ModeTab>Decoded</ModeTab> + <ModeTab>Raw</ModeTab> + </div> + </Tab.List> + <Tab.Panels as={Fragment}> + <Tab.Panel className="space-y-2"> + {logDesc === undefined ? ( + <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> + <div className="col-start-2 flex space-x-2 items-center col-span-11"> + Waiting for data... + </div> + </div> + ) : logDesc === null ? ( + <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> + <div className="col-start-2 flex space-x-2 items-center col-span-11"> + No decoded data + </div> + </div> + ) : ( + <> + <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> + <div className="col-start-2 flex space-x-2 items-center col-span-11 font-mono"> + <DecodedLogSignature event={logDesc.eventFragment} /> + </div> + </div> + <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> + <div className="col-start-2 flex space-x-2 items-center col-span-11"> + <DecodedParamsTable + args={logDesc.args} + paramTypes={logDesc.eventFragment.inputs} + txData={txData} + /> + </div> + </div> + </> + )} + </Tab.Panel> + <Tab.Panel className="space-y-2"> + {log.topics.map((t, i) => ( + <div + className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm" + key={i} + > + <div className="text-right">{i === 0 && "Topics"}</div> + <div className="flex space-x-2 items-center col-span-11 font-mono"> + <span className="rounded bg-gray-100 text-gray-500 px-2 py-1 text-xs"> + {i} + </span> + <span>{t}</span> + </div> + </div> + ))} + <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> + <div className="text-right pt-2">Data</div> + <div className="col-span-11"> + <textarea + className="w-full h-40 bg-gray-50 font-mono focus:outline-none border rounded p-2" + value={log.data} + readOnly + /> + </div> + </div> + </Tab.Panel> + </Tab.Panels> + </Tab.Group> + </div> + </div> +); + +export default React.memo(LogEntry); diff --git a/src/transaction/Logs.tsx b/src/transaction/Logs.tsx index e57dc6a..45fe491 100644 --- a/src/transaction/Logs.tsx +++ b/src/transaction/Logs.tsx @@ -1,63 +1,85 @@ -import React from "react"; +import React, { useMemo } from "react"; +import { Interface } from "@ethersproject/abi"; import ContentFrame from "../ContentFrame"; -import DecoratedAddressLink from "../components/DecoratedAddressLink"; +import LogEntry from "./LogEntry"; import { TransactionData } from "../types"; +import { useAppConfigContext } from "../useAppConfig"; +import { Metadata, useMultipleMetadata } from "../useSourcify"; type LogsProps = { txData: TransactionData; + metadata: Metadata | null | undefined; }; -const Logs: React.FC<LogsProps> = ({ txData }) => ( - <ContentFrame tabs> - <div className="text-sm py-4">Transaction Receipt Event Logs</div> - {txData.confirmedData && - txData.confirmedData.logs.map((l, i) => ( - <div className="flex space-x-10 py-5" key={i}> - <div> - <span className="rounded-full w-12 h-12 flex items-center justify-center bg-green-50 text-green-500"> - {l.logIndex} - </span> - </div> - <div className="w-full space-y-2"> - <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> - <div className="font-bold text-right">Address</div> - <div className="col-span-11 mr-auto"> - <DecoratedAddressLink - address={l.address} - miner={l.address === txData.confirmedData?.miner} - txFrom={l.address === txData.from} - txTo={l.address === txData.to} +const Logs: React.FC<LogsProps> = ({ txData, metadata }) => { + const baseMetadatas = useMemo((): Record<string, Metadata | null> => { + if (!txData.to || metadata === undefined) { + return {}; + } + + const md: Record<string, Metadata | null> = {}; + md[txData.to] = metadata; + return md; + }, [txData.to, metadata]); + + const { sourcifySource } = useAppConfigContext(); + const logAddresses = useMemo( + () => txData.confirmedData?.logs.map((l) => l.address) ?? [], + [txData] + ); + const metadatas = useMultipleMetadata( + baseMetadatas, + logAddresses, + 1, + sourcifySource + ); + const logDescs = useMemo(() => { + if (!txData) { + return undefined; + } + + return txData.confirmedData?.logs.map((l) => { + const mt = metadatas[l.address]; + if (!mt) { + return mt; + } + + const abi = mt.output.abi; + const intf = new Interface(abi as any); + try { + return intf.parseLog({ + topics: l.topics, + data: l.data, + }); + } catch (err) { + console.warn("Couldn't find function signature", err); + return null; + } + }); + }, [metadatas, txData]); + + return ( + <ContentFrame tabs> + {txData.confirmedData && ( + <> + {txData.confirmedData.logs.length > 0 ? ( + <> + {txData.confirmedData.logs.map((l, i) => ( + <LogEntry + key={i} + txData={txData} + log={l} + logDesc={logDescs?.[i]} /> - </div> - </div> - {l.topics.map((t, i) => ( - <div - className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm" - key={i} - > - <div className="text-right">{i === 0 && "Topics"}</div> - <div className="flex space-x-2 items-center col-span-11 font-mono"> - <span className="rounded bg-gray-100 text-gray-500 px-2 py-1 text-xs"> - {i} - </span> - <span>{t}</span> - </div> - </div> - ))} - <div className="grid grid-cols-12 gap-x-3 gap-y-5 text-sm"> - <div className="text-right pt-2">Data</div> - <div className="col-span-11"> - <textarea - className="w-full h-20 bg-gray-50 font-mono focus:outline-none border rounded p-2" - value={l.data} - readOnly - /> - </div> - </div> - </div> - </div> - ))} - </ContentFrame> -); + ))} + </> + ) : ( + <div className="text-sm py-4">Transaction didn't emit any logs</div> + )} + </> + )} + </ContentFrame> + ); +}; export default React.memo(Logs); diff --git a/src/useAppConfig.ts b/src/useAppConfig.ts new file mode 100644 index 0000000..56007e0 --- /dev/null +++ b/src/useAppConfig.ts @@ -0,0 +1,13 @@ +import React, { useContext } from "react"; +import { SourcifySource } from "./url"; + +export type AppConfig = { + sourcifySource: SourcifySource; + setSourcifySource: (newSourcifySource: SourcifySource) => void; +}; + +export const AppConfigContext = React.createContext<AppConfig>(undefined!); + +export const useAppConfigContext = () => { + return useContext(AppConfigContext); +}; diff --git a/src/useErigonHooks.ts b/src/useErigonHooks.ts index 477f7b4..7322a0d 100644 --- a/src/useErigonHooks.ts +++ b/src/useErigonHooks.ts @@ -225,16 +225,20 @@ export const useTxData = ( continue; } const erc20Contract = new Contract(t.token, erc20, provider); - const [name, symbol, decimals] = await Promise.all([ - erc20Contract.name(), - erc20Contract.symbol(), - erc20Contract.decimals(), - ]); - tokenMetas[t.token] = { - name, - symbol, - decimals, - }; + try { + const [name, symbol, decimals] = await Promise.all([ + erc20Contract.name(), + erc20Contract.symbol(), + erc20Contract.decimals(), + ]); + tokenMetas[t.token] = { + name, + symbol, + decimals, + }; + } catch (err) { + console.warn(`Couldn't get token ${t.token} metadata; ignoring`, err); + } } setTxData({ diff --git a/src/useSourcify.ts b/src/useSourcify.ts index bab8423..b60fde9 100644 --- a/src/useSourcify.ts +++ b/src/useSourcify.ts @@ -1,4 +1,6 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; +import { Interface } from "@ethersproject/abi"; +import { TransactionData } from "./types"; import { sourcifyMetadata, SourcifySource, sourcifySourceFile } from "./url"; export type Metadata = { @@ -36,11 +38,38 @@ export type Metadata = { }; }; +export const fetchSourcifyMetadata = async ( + checksummedAddress: string, + chainId: number, + source: SourcifySource, + abortController: AbortController +): Promise<Metadata | null> => { + try { + const contractMetadataURL = sourcifyMetadata( + checksummedAddress, + chainId, + source + ); + const result = await fetch(contractMetadataURL, { + signal: abortController.signal, + }); + if (result.ok) { + const _metadata = await result.json(); + return _metadata; + } + + return null; + } catch (err) { + console.error(err); + return null; + } +}; + export const useSourcify = ( checksummedAddress: string | undefined, chainId: number | undefined, source: SourcifySource -) => { +): Metadata | null | undefined => { const [rawMetadata, setRawMetadata] = useState<Metadata | null | undefined>(); useEffect(() => { @@ -51,25 +80,13 @@ export const useSourcify = ( const abortController = new AbortController(); const fetchMetadata = async () => { - try { - const contractMetadataURL = sourcifyMetadata( - checksummedAddress, - chainId, - source - ); - const result = await fetch(contractMetadataURL, { - signal: abortController.signal, - }); - if (result.ok) { - const _metadata = await result.json(); - setRawMetadata(_metadata); - } else { - setRawMetadata(null); - } - } catch (err) { - console.error(err); - setRawMetadata(null); - } + const _metadata = await fetchSourcifyMetadata( + checksummedAddress, + chainId, + source, + abortController + ); + setRawMetadata(_metadata); }; fetchMetadata(); @@ -81,6 +98,54 @@ export const useSourcify = ( return rawMetadata; }; +export const useMultipleMetadata = ( + baseMetadatas: Record<string, Metadata | null>, + checksummedAddress: (string | undefined)[], + chainId: number | undefined, + source: SourcifySource +): Record<string, Metadata | null | undefined> => { + const [rawMetadata, setRawMetadata] = useState< + Record<string, Metadata | null | undefined> + >({}); + + useEffect(() => { + if (!checksummedAddress || chainId === undefined) { + return; + } + setRawMetadata({}); + + const abortController = new AbortController(); + const fetchMetadata = async (addresses: string[]) => { + const promises: Promise<Metadata | null>[] = []; + for (const addr of addresses) { + promises.push( + fetchSourcifyMetadata(addr, chainId, source, abortController) + ); + } + + const results = await Promise.all(promises); + const metadatas: Record<string, Metadata | null> = { ...baseMetadatas }; + for (let i = 0; i < results.length; i++) { + metadatas[addresses[i]] = results[i]; + } + setRawMetadata(metadatas); + }; + + const deduped = new Set( + checksummedAddress.filter( + (a): a is string => a !== undefined && baseMetadatas[a] === undefined + ) + ); + fetchMetadata(Array.from(deduped)); + + return () => { + abortController.abort(); + }; + }, [baseMetadatas, checksummedAddress, chainId, source]); + + return rawMetadata; +}; + export const useContract = ( checksummedAddress: string, networkId: number, @@ -119,3 +184,31 @@ export const useContract = ( return content; }; + +export const useTransactionDescription = ( + metadata: Metadata | null | undefined, + txData: TransactionData | null | undefined +) => { + const txDesc = useMemo(() => { + if (metadata === null) { + return null; + } + if (!metadata || !txData) { + return undefined; + } + + const abi = metadata.output.abi; + const intf = new Interface(abi as any); + try { + return intf.parseTransaction({ + data: txData.data, + value: txData.value, + }); + } catch (err) { + console.warn("Couldn't find function signature", err); + return null; + } + }, [metadata, txData]); + + return txDesc; +};