diff --git a/.dockerignore b/.dockerignore index df120e1..ee24273 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,6 @@ node_modules 4bytes !4bytes/signatures +!4bytes/with_parameter_names trustwallet !trustwallet/blockchains/ethereum/assets diff --git a/Dockerfile b/Dockerfile index 7583d23..af322ed 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,14 @@ WORKDIR /assets COPY trustwallet/blockchains/ethereum/assets /assets/ RUN find . -name logo.png | parallel magick convert {} -filter Lanczos -resize 32x32 {} +FROM alpine:3.14.0 AS fourbytesbuilder +WORKDIR /signatures +COPY 4bytes/signatures /signatures/ +COPY 4bytes/with_parameter_names /signatures/ + FROM nginx:1.21.1-alpine RUN apk add jq -COPY 4bytes/signatures /usr/share/nginx/html/signatures/ +COPY --from=fourbytesbuilder /signatures /usr/share/nginx/html/signatures/ COPY --from=logobuilder /assets /usr/share/nginx/html/assets/ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY --from=builder /otterscan-build/build /usr/share/nginx/html/ diff --git a/package.json b/package.json index 96ba0b7..1cf8dfc 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "eject": "react-scripts eject", "source-map-explorer": "source-map-explorer build/static/js/*.js", "assets-start": "docker run --rm -p 3001:80 --name otterscan-assets -d -v$(pwd)/4bytes/signatures:/usr/share/nginx/html/signatures/ -v$(pwd)/trustwallet/blockchains/ethereum/assets:/usr/share/nginx/html/assets -v$(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf nginx:1.21.1-alpine", + "assets-start-with-param-names": "docker run --rm -p 3001:80 --name otterscan-assets -d -v$(pwd)/4bytes/with_parameter_names:/usr/share/nginx/html/signatures/ -v$(pwd)/trustwallet/blockchains/ethereum/assets:/usr/share/nginx/html/assets -v$(pwd)/nginx.conf:/etc/nginx/conf.d/default.conf nginx:1.21.1-alpine", "assets-stop": "docker stop otterscan-assets", "docker-build": "DOCKER_BUILDKIT=1 docker build -t otterscan -f Dockerfile .", "docker-start": "docker run --rm -p 5000:80 --name otterscan -d otterscan", diff --git a/src/TokenTransferItem.tsx b/src/TokenTransferItem.tsx index c9f40a2..083f7f0 100644 --- a/src/TokenTransferItem.tsx +++ b/src/TokenTransferItem.tsx @@ -7,7 +7,7 @@ import DecoratedAddressLink from "./components/DecoratedAddressLink"; import FormattedBalance from "./components/FormattedBalance"; import { AddressContext, - TokenMetas, + TokenMeta, TokenTransfer, TransactionData, } from "./types"; @@ -15,14 +15,14 @@ import { type TokenTransferItemProps = { t: TokenTransfer; txData: TransactionData; - tokenMetas: TokenMetas; + tokenMeta?: TokenMeta | undefined; }; // TODO: handle partial const TokenTransferItem: React.FC = ({ t, txData, - tokenMetas, + tokenMeta, }) => (
@@ -57,7 +57,7 @@ const TokenTransferItem: React.FC = ({ @@ -65,11 +65,9 @@ const TokenTransferItem: React.FC = ({
diff --git a/src/components/MethodName.tsx b/src/components/MethodName.tsx index 1ed596d..c965a81 100644 --- a/src/components/MethodName.tsx +++ b/src/components/MethodName.tsx @@ -1,14 +1,15 @@ import React from "react"; -import { use4Bytes } from "../use4Bytes"; +import { rawInputTo4Bytes, use4Bytes } from "../use4Bytes"; type MethodNameProps = { data: string; }; const MethodName: React.FC = ({ data }) => { - const rawFourBytes = data.slice(0, 10); - const methodName = use4Bytes(rawFourBytes); - const isSimpleTransfer = data === "0x"; + const rawFourBytes = rawInputTo4Bytes(data); + const fourBytesEntry = use4Bytes(rawFourBytes); + const methodName = fourBytesEntry?.name ?? rawFourBytes; + const isSimpleTransfer = rawFourBytes === "0x"; const methodTitle = isSimpleTransfer ? "ETH Transfer" : methodName === rawFourBytes diff --git a/src/transaction/DecodedParamRow.tsx b/src/transaction/DecodedParamRow.tsx deleted file mode 100644 index 9d9bcf8..0000000 --- a/src/transaction/DecodedParamRow.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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 = ({ - prefix, - i, - r, - paramType, - txData, -}) => { - return ( - <> - - - {prefix && {prefix}} - {paramType.name}{" "} - {i !== undefined && ( - ({i}) - )} - - {paramType.type} - - {paramType.baseType === "address" ? ( -
- - - - -
- ) : paramType.baseType === "bool" ? ( - - {r.toString()} - - ) : paramType.baseType === "bytes" ? ( - - {r.toString()}{" "} - - {r.toString().length / 2 - 1}{" "} - {r.toString().length / 2 - 1 === 1 ? "byte" : "bytes"} - - - ) : paramType.baseType === "tuple" ? ( - <> - ) : ( - r.toString() - )} - - - {paramType.baseType === "tuple" && - r.map((e: any, idx: number) => ( - - ))} - - ); -}; - -export default React.memo(DecodedParamRow); diff --git a/src/transaction/Details.tsx b/src/transaction/Details.tsx index bea1587..bfd8747 100644 --- a/src/transaction/Details.tsx +++ b/src/transaction/Details.tsx @@ -1,5 +1,9 @@ import React, { useMemo } from "react"; -import { TransactionDescription } from "@ethersproject/abi"; +import { + TransactionDescription, + Fragment, + Interface, +} from "@ethersproject/abi"; import { BigNumber } from "@ethersproject/bignumber"; import { toUtf8String } from "@ethersproject/strings"; import { Tab } from "@headlessui/react"; @@ -31,7 +35,8 @@ import ExternalLink from "../components/ExternalLink"; import RelativePosition from "../components/RelativePosition"; import PercentagePosition from "../components/PercentagePosition"; import ModeTab from "../components/ModeTab"; -import DecodedParamsTable from "./DecodedParamsTable"; +import DecodedParamsTable from "./decoder/DecodedParamsTable"; +import { rawInputTo4Bytes, use4Bytes } from "../use4Bytes"; type DetailsProps = { txData: TransactionData; @@ -62,6 +67,20 @@ const Details: React.FC = ({ } }, [txData]); + const fourBytes = rawInputTo4Bytes(txData.data); + const fourBytesEntry = use4Bytes(fourBytes); + const fourBytesTxDesc = useMemo(() => { + 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 resolvedTxDesc = txDesc ?? fourBytesTxDesc; + return ( @@ -187,7 +206,7 @@ const Details: React.FC = ({ key={i} t={t} txData={txData} - tokenMetas={txData.tokenMetas} + tokenMeta={txData.tokenMetas[t.token]} /> ))} @@ -322,15 +341,18 @@ const Details: React.FC = ({ - {txDesc === undefined ? ( + {fourBytes === "0x" ? ( + <>No parameters + ) : resolvedTxDesc === undefined ? ( <>Waiting for data... - ) : txDesc === null ? ( + ) : resolvedTxDesc === null ? ( <>No decoded data ) : ( )} diff --git a/src/transaction/LogEntry.tsx b/src/transaction/LogEntry.tsx index 1d4e85f..e06c558 100644 --- a/src/transaction/LogEntry.tsx +++ b/src/transaction/LogEntry.tsx @@ -6,8 +6,8 @@ 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 DecodedParamsTable from "./decoder/DecodedParamsTable"; +import DecodedLogSignature from "./decoder/DecodedLogSignature"; import { TransactionData } from "../types"; type LogEntryProps = { diff --git a/src/transaction/decoder/AddressDecoder.tsx b/src/transaction/decoder/AddressDecoder.tsx new file mode 100644 index 0000000..ee6369a --- /dev/null +++ b/src/transaction/decoder/AddressDecoder.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import AddressHighlighter from "../../components/AddressHighlighter"; +import DecoratedAddressLink from "../../components/DecoratedAddressLink"; +import Copy from "../../components/Copy"; +import { TransactionData } from "../../types"; + +type AddressDecoderProps = { + r: any; + txData: TransactionData; +}; + +const AddressDecoder: React.FC = ({ r, txData }) => ( +
+ + + + +
+); + +export default React.memo(AddressDecoder); diff --git a/src/transaction/decoder/BooleanDecoder.tsx b/src/transaction/decoder/BooleanDecoder.tsx new file mode 100644 index 0000000..259f4a9 --- /dev/null +++ b/src/transaction/decoder/BooleanDecoder.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +type BooleanDecoderProps = { + r: any; +}; + +const BooleanDecoder: React.FC = ({ r }) => ( + + {r.toString()} + +); + +export default React.memo(BooleanDecoder); diff --git a/src/transaction/decoder/BytesDecoder.tsx b/src/transaction/decoder/BytesDecoder.tsx new file mode 100644 index 0000000..f7de95f --- /dev/null +++ b/src/transaction/decoder/BytesDecoder.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +type BytesDecoderProps = { + r: any; +}; + +const BytesDecoder: React.FC = ({ r }) => ( + + {r.toString()}{" "} + + {r.toString().length / 2 - 1}{" "} + {r.toString().length / 2 - 1 === 1 ? "byte" : "bytes"} + + +); + +export default React.memo(BytesDecoder); diff --git a/src/transaction/DecodedLogSignature.tsx b/src/transaction/decoder/DecodedLogSignature.tsx similarity index 100% rename from src/transaction/DecodedLogSignature.tsx rename to src/transaction/decoder/DecodedLogSignature.tsx diff --git a/src/transaction/decoder/DecodedParamRow.tsx b/src/transaction/decoder/DecodedParamRow.tsx new file mode 100644 index 0000000..988e536 --- /dev/null +++ b/src/transaction/decoder/DecodedParamRow.tsx @@ -0,0 +1,92 @@ +import React, { ReactNode } from "react"; +import { ParamType } from "@ethersproject/abi"; +import Uint256Decoder from "./Uint256Decoder"; +import AddressDecoder from "./AddressDecoder"; +import BooleanDecoder from "./BooleanDecoder"; +import BytesDecoder from "./BytesDecoder"; +import { TransactionData } from "../../types"; + +type DecodedParamRowProps = { + prefix?: ReactNode; + i?: number | undefined; + r: any; + paramType: ParamType; + txData: TransactionData; + arrayElem?: number | undefined; +}; + +const DecodedParamRow: React.FC = ({ + prefix, + i, + r, + paramType, + txData, + arrayElem, +}) => ( + <> + + + {prefix && {prefix}} + {arrayElem !== undefined ? ( + + {" "} + [{arrayElem}] + + ) : ( + <> + {paramType.name ?? param_{i}}{" "} + {i !== undefined && ( + ({i}) + )} + + )} + + {paramType.type} + + {paramType.baseType === "uint256" ? ( + + ) : paramType.baseType === "address" ? ( + + ) : paramType.baseType === "bool" ? ( + + ) : paramType.baseType === "bytes" ? ( + + ) : paramType.baseType === "tuple" || paramType.baseType === "array" ? ( + <> + ) : ( + r.toString() + )} + + + {paramType.baseType === "tuple" && + r.map((e: any, idx: number) => ( + param_{i}. + ) + } + i={idx} + r={e} + paramType={paramType.components[idx]} + txData={txData} + /> + ))} + {paramType.baseType === "array" && + r.map((e: any, idx: number) => ( + param_{i}} + r={e} + paramType={paramType.arrayChildren} + txData={txData} + arrayElem={idx} + /> + ))} + +); + +export default React.memo(DecodedParamRow); diff --git a/src/transaction/DecodedParamsTable.tsx b/src/transaction/decoder/DecodedParamsTable.tsx similarity index 65% rename from src/transaction/DecodedParamsTable.tsx rename to src/transaction/decoder/DecodedParamsTable.tsx index ff439d8..636dd67 100644 --- a/src/transaction/DecodedParamsTable.tsx +++ b/src/transaction/decoder/DecodedParamsTable.tsx @@ -1,20 +1,22 @@ import React from "react"; import { ParamType, Result } from "@ethersproject/abi"; import DecodedParamRow from "./DecodedParamRow"; -import { TransactionData } from "../types"; +import { TransactionData } from "../../types"; type DecodedParamsTableProps = { args: Result; paramTypes: ParamType[]; txData: TransactionData; + hasParamNames?: boolean; }; const DecodedParamsTable: React.FC = ({ args, paramTypes, txData, + hasParamNames = true, }) => ( - +
+ {!hasParamNames && ( + + + + )} {args.map((r, i) => ( diff --git a/src/transaction/decoder/Uint256Decoder.tsx b/src/transaction/decoder/Uint256Decoder.tsx new file mode 100644 index 0000000..7d4a003 --- /dev/null +++ b/src/transaction/decoder/Uint256Decoder.tsx @@ -0,0 +1,69 @@ +import React, { useState } from "react"; +import { BigNumber } from "@ethersproject/bignumber"; +import { hexlify, hexZeroPad } from "@ethersproject/bytes"; +import { commify, formatEther } from "@ethersproject/units"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faSync } from "@fortawesome/free-solid-svg-icons/faSync"; + +type Uint256DecoderProps = { + r: any; +}; + +enum DisplayMode { + RAW, + EIGHTEEN_DECIMALS, + HEX, + _LAST, +} + +const VERY_BIG_NUMBER = BigNumber.from(10).pow(BigNumber.from(36)); + +const initDisplayMode = (r: any): DisplayMode => { + const n = BigNumber.from(r); + if (n.gte(VERY_BIG_NUMBER)) { + return DisplayMode.HEX; + } + return DisplayMode.RAW; +}; + +const Uint256Decoder: React.FC = ({ r }) => { + const [displayMode, setDisplayMode] = useState( + initDisplayMode(r) + ); + + const toggleModes = () => { + const next = displayMode + 1; + setDisplayMode(next === DisplayMode._LAST ? 0 : next); + }; + + return ( +
+ + + {displayMode === DisplayMode.RAW ? ( + <>{commify(r.toString())} + ) : displayMode === DisplayMode.HEX ? ( + <>{hexZeroPad(hexlify(r), 32)} + ) : ( + <>{commify(formatEther(r))} + )} + +
+ ); +}; + +export default React.memo(Uint256Decoder); diff --git a/src/types.ts b/src/types.ts index 5a5dad9..5904b31 100644 --- a/src/types.ts +++ b/src/types.ts @@ -108,6 +108,4 @@ export type TokenMeta = { decimals: number; }; -export type TokenMetas = { - [tokenAddress: string]: TokenMeta; -}; +export type TokenMetas = Record; diff --git a/src/use4Bytes.ts b/src/use4Bytes.ts index dad9f75..9fe9252 100644 --- a/src/use4Bytes.ts +++ b/src/use4Bytes.ts @@ -2,16 +2,48 @@ import { useState, useEffect, useContext } from "react"; import { RuntimeContext } from "./useRuntime"; import { fourBytesURL } from "./url"; -const cache = new Map(); +export type FourBytesEntry = { + name: string; + signature: string | undefined; +}; + +const simpleTransfer: FourBytesEntry = { + name: "Transfer", + signature: undefined, +}; + +const fullCache = new Map(); + +export const rawInputTo4Bytes = (rawInput: string) => rawInput.slice(0, 10); + +/** + * Extract 4bytes DB info + * + * @param rawFourBytes an hex string containing the 4bytes signature in the "0xXXXXXXXX" format. + */ +export const use4Bytes = ( + rawFourBytes: string +): FourBytesEntry | null | undefined => { + if (rawFourBytes !== "0x") { + if (rawFourBytes.length !== 10 || !rawFourBytes.startsWith("0x")) { + throw new Error( + `rawFourBytes must contain a 4 bytes hex method signature starting with 0x; received value: "${rawFourBytes}"` + ); + } + } -export const use4Bytes = (rawFourBytes: string) => { const runtime = useContext(RuntimeContext); const assetsURLPrefix = runtime.config?.assetsURLPrefix; - const [name, setName] = useState(); - const [fourBytes, setFourBytes] = useState(); + const fourBytes = rawFourBytes.slice(2); + const [entry, setEntry] = useState( + fullCache.get(fourBytes) + ); useEffect(() => { - if (assetsURLPrefix === undefined || fourBytes === undefined) { + if (assetsURLPrefix === undefined) { + return; + } + if (fourBytes === "") { return; } @@ -20,51 +52,47 @@ export const use4Bytes = (rawFourBytes: string) => { .then(async (res) => { if (!res.ok) { console.error(`Signature does not exist in 4bytes DB: ${fourBytes}`); - - // Use the default 4 bytes as name - setName(rawFourBytes); - cache.set(fourBytes, null); + fullCache.set(fourBytes, null); + setEntry(null); return; } - const sig = await res.text(); + // 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); - setName(method); - cache.set(fourBytes, method); - return; + + const entry: FourBytesEntry = { + name: method, + signature: sig, + }; + setEntry(entry); + fullCache.set(fourBytes, entry); }) .catch((err) => { console.error(`Couldn't fetch signature URL ${signatureURL}`, err); - - // Use the default 4 bytes as name - setName(rawFourBytes); + setEntry(null); + fullCache.set(fourBytes, null); }); - }, [rawFourBytes, assetsURLPrefix, fourBytes]); + }, [fourBytes, assetsURLPrefix]); if (rawFourBytes === "0x") { - return "Transfer"; + return simpleTransfer; } if (assetsURLPrefix === undefined) { - return rawFourBytes; + return undefined; } // Try to resolve 4bytes name - const entry = cache.get(rawFourBytes.slice(2)); - if (entry === null) { - return rawFourBytes; - } - if (entry !== undefined) { - // Simulates LRU - cache.delete(entry); - cache.set(rawFourBytes.slice(2), entry); + if (entry === null || entry === undefined) { return entry; } - if (name === undefined && fourBytes === undefined) { - setFourBytes(rawFourBytes.slice(2)); - return ""; - } - return name; + // Simulates LRU + // TODO: implement LRU purging + fullCache.delete(fourBytes); + fullCache.set(fourBytes, entry); + return entry; };
@@ -23,6 +25,15 @@ const DecodedParamsTable: React.FC = ({ type value
+ {paramTypes.length > 0 && paramTypes[0].name !== null + ? "Parameter names are estimated." + : "Parameter names are not available."} +