├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── Dockerfile ├── README.md ├── components ├── Chains.ts ├── DataRenderer.tsx ├── EncodedABITextField.tsx ├── FragmentTextField.tsx ├── Navbar.tsx ├── ParamFlatView.tsx ├── ParamTreeView.tsx ├── SpanIconButton.tsx ├── api.tsx ├── decoder-format │ ├── DecodeTree.tsx │ ├── ens.tsx │ ├── formatter.ts │ ├── swap.tsx │ ├── transfer.tsx │ ├── types.tsx │ └── wrapped.tsx ├── ethers │ └── json-rpc-batch-provider.ts ├── gas-price-estimator │ └── estimate.ts ├── helpers.tsx ├── hooks │ └── useFragment.tsx ├── knownSlots.tsx ├── metadata │ ├── labels.ts │ ├── preimages.ts │ ├── prices.ts │ ├── search.ts │ ├── tokens.ts │ ├── transaction.ts │ └── types.ts ├── precompiles.tsx ├── trace │ ├── CallTraceTreeItem.tsx │ ├── LogTraceTreeItem.tsx │ ├── SloadTraceTreeItem.tsx │ ├── SlotTree.tsx │ ├── SstoreTraceTreeItem.tsx │ ├── TraceTree.tsx │ ├── TraceTreeDialog.tsx │ └── TraceTreeItem.tsx ├── transaction-info │ └── TransactionInfo.tsx ├── types.tsx └── value-change │ └── ValueChange.tsx ├── jest.config.js ├── next.config.js ├── package.json ├── pages ├── [chain] │ └── [txhash].tsx ├── _app.tsx └── index.tsx ├── pnpm-lock.yaml ├── prettier.config.js ├── public ├── favicon.png ├── fonts │ ├── NBInternational.woff2 │ └── RiformaLLSub.woff2 └── images │ ├── fullscreen.png │ ├── github.png │ ├── minimize.png │ └── twitter.png ├── styles ├── Home.module.css └── globals.css ├── tests.txt └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .idea 39 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | package-lock.json 3 | pnpm-lock.yaml 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Install dependencies only when needed 2 | FROM node:16-alpine AS deps 3 | 4 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 5 | RUN apk add --no-cache libc6-compat && npm install -g pnpm 6 | WORKDIR /app 7 | COPY package.json ./ 8 | COPY pnpm-lock.yaml ./ 9 | RUN pnpm install 10 | 11 | # Rebuild the source code only when needed 12 | FROM node:16-alpine AS builder 13 | WORKDIR /app 14 | RUN apk add --no-cache libc6-compat && npm install -g pnpm 15 | COPY --from=deps /app/node_modules ./node_modules 16 | COPY . . 17 | 18 | # Next.js collects completely anonymous telemetry data about general usage. 19 | # Learn more here: https://nextjs.org/telemetry 20 | # Uncomment the following line in case you want to disable telemetry during the build. 21 | ENV NEXT_TELEMETRY_DISABLED 1 22 | 23 | RUN pnpm run build 24 | 25 | # Production image, copy all the files and run next 26 | FROM node:16-alpine AS runner 27 | WORKDIR /app 28 | 29 | ENV NODE_ENV production 30 | # Uncomment the following line in case you want to disable telemetry during runtime. 31 | ENV NEXT_TELEMETRY_DISABLED 1 32 | 33 | RUN addgroup --system --gid 1001 nodejs 34 | RUN adduser --system --uid 1001 nextjs 35 | 36 | COPY --from=builder /app/public ./public 37 | 38 | # Automatically leverage output traces to reduce image size 39 | # https://nextjs.org/docs/advanced-features/output-file-tracing 40 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 41 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 42 | 43 | USER nextjs 44 | 45 | EXPOSE 3000 46 | 47 | ENV PORT 3000 48 | 49 | CMD ["node", "server.js"] 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ethereum-tracing-srv frontend 2 | 3 | This is the frontend to the [Ethereum Transaction Viewer](https://tx.eth.samczsun.com/). By default, it's configured 4 | to use the production backend. 5 | 6 | TypeScript is disabled as the code is still kind of spaghetti and doesn't fully type-check. I'm not a frontend dev 7 | so I doubt much of it is idiomatic either. 8 | 9 | The main rendering logic is in [index.tsx](pages/index.tsx), which then 10 | delegates out to the various components in [components/trace](components/trace). There's 11 | a lot of duplicated code from rapidly prototyping a schema that works. 12 | 13 | To bring up the frontend, just 14 | 15 | ```bash 16 | pnpm install 17 | pnpm run dev 18 | ``` 19 | 20 | To build an image, just 21 | 22 | ```bash 23 | docker build . 24 | ``` 25 | -------------------------------------------------------------------------------- /components/Chains.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type ChainConfig = { 4 | chainId: number; 5 | id: string; 6 | displayName: string; 7 | nativeTokenAddress: string; 8 | nativeSymbol: string; 9 | coingeckoId: string; 10 | defillamaPrefix: string; 11 | rpcUrl: string; 12 | blockexplorerUrl: string; 13 | }; 14 | 15 | export const SupportedChains = [ 16 | { 17 | chainId: 1, 18 | id: 'ethereum', 19 | displayName: 'Ethereum', 20 | nativeTokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 21 | nativeSymbol: 'ETH', 22 | coingeckoId: 'coingecko:ethereum', 23 | defillamaPrefix: 'ethereum', 24 | rpcUrl: 'https://rpc.ankr.com/eth', 25 | blockexplorerUrl: 'https://etherscan.io', 26 | }, 27 | { 28 | chainId: 137, 29 | id: 'polygon', 30 | displayName: 'Polygon', 31 | nativeTokenAddress: '0x0eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 32 | nativeSymbol: 'MATIC', 33 | coingeckoId: 'coingecko:matic-network', 34 | defillamaPrefix: 'polygon', 35 | rpcUrl: 'https://rpc.ankr.com/polygon', 36 | blockexplorerUrl: 'https://polygonscan.com', 37 | }, 38 | { 39 | chainId: 10, 40 | id: 'optimism', 41 | displayName: 'Optimism', 42 | nativeTokenAddress: '0x1eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 43 | nativeSymbol: 'ETH', 44 | coingeckoId: 'coingecko:ethereum', 45 | defillamaPrefix: 'optimism', 46 | rpcUrl: 'https://mainnet.optimism.io', 47 | blockexplorerUrl: 'https://optimistic.etherscan.io', 48 | }, 49 | { 50 | chainId: 56, 51 | id: 'binance', 52 | displayName: 'Binance', 53 | nativeTokenAddress: '0x2eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 54 | nativeSymbol: 'BNB', 55 | coingeckoId: 'coingecko:binancecoin', 56 | defillamaPrefix: 'bsc', 57 | rpcUrl: 'https://rpc.ankr.com/bsc', 58 | blockexplorerUrl: 'https://bscscan.com', 59 | }, 60 | { 61 | chainId: 43112, 62 | id: 'avalanche', 63 | displayName: 'Avalanche', 64 | nativeTokenAddress: '0x3eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 65 | nativeSymbol: 'AVAX', 66 | coingeckoId: 'coingecko:avalanche-2', 67 | defillamaPrefix: 'avax', 68 | rpcUrl: 'https://rpc.ankr.com/avalanche', 69 | blockexplorerUrl: 'https://snowtrace.io', 70 | }, 71 | { 72 | chainId: 42161, 73 | id: 'arbitrum', 74 | displayName: 'Arbitrum', 75 | nativeTokenAddress: '0x4eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 76 | nativeSymbol: 'ETH', 77 | coingeckoId: 'coingecko:ethereum', 78 | defillamaPrefix: 'arbitrum', 79 | rpcUrl: 'https://arb1.arbitrum.io/rpc', 80 | blockexplorerUrl: 'https://arbiscan.io', 81 | }, 82 | { 83 | chainId: 250, 84 | id: 'fantom', 85 | displayName: 'Fantom', 86 | nativeTokenAddress: '0x5eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', 87 | nativeSymbol: 'FTM', 88 | coingeckoId: 'coingecko:fantom', 89 | defillamaPrefix: 'fantom', 90 | rpcUrl: 'https://rpcapi.fantom.network', 91 | blockexplorerUrl: 'https://ftmscan.com', 92 | }, 93 | ]; 94 | 95 | const conduitAPIs: { [key: string]: string } = { 96 | conduit: 'https://api.exfac.xyz/txTracer/chainConfig/', 97 | 'conduit-staging': 'https://api.staging.exfac.xyz/txTracer/chainConfig/', 98 | 'conduit-localhost': 'http://localhost:8080/txTracer/chainConfig/', 99 | }; 100 | 101 | export const getChain = async (id: string): Promise => { 102 | if (id.startsWith('conduit:') || id.startsWith('conduit-staging:') || id.startsWith('conduit-localhost:')) { 103 | const tokens = id.split(':'); 104 | if (tokens.length != 2) { 105 | return undefined; 106 | } 107 | 108 | const prefix = tokens[0]; 109 | const slug = tokens[1]; 110 | 111 | try { 112 | let resp = await fetch(conduitAPIs[prefix] + slug); 113 | let json = await resp.json(); 114 | return json as ChainConfig; 115 | } catch (error) { 116 | console.log(error); 117 | } 118 | return undefined; 119 | } 120 | return SupportedChains.find((chain) => chain.id === id); 121 | }; 122 | 123 | export const defaultChainConfig = (): ChainConfig => { 124 | return SupportedChains[0]; 125 | }; 126 | 127 | export const ChainConfigContext = React.createContext(defaultChainConfig()); 128 | -------------------------------------------------------------------------------- /components/DataRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { ParamType } from '@ethersproject/abi/lib'; 2 | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; 3 | import { styled, Tooltip, tooltipClasses, TooltipProps } from '@mui/material'; 4 | import { BigNumber, ethers } from 'ethers'; 5 | import { useContext } from 'react'; 6 | import { ChainConfig, ChainConfigContext } from './Chains'; 7 | import { LabelMetadataContext } from './metadata/labels'; 8 | import { PreimageMetadataContext } from './metadata/preimages'; 9 | import { SpanIconButton } from './SpanIconButton'; 10 | 11 | const stringifyValue = (paramType: ParamType, value: any): string => { 12 | if (paramType.indexed && value.hash) { 13 | return value.hash; 14 | } 15 | 16 | if (paramType.baseType === 'address') { 17 | return ethers.utils.getAddress(value.toString()); 18 | } 19 | 20 | return value.toString(); 21 | }; 22 | 23 | let formatValueWithParamType = ( 24 | paramType: ParamType, 25 | chainConfig: ChainConfig, 26 | value: string, 27 | truncate: boolean, 28 | makeLink: boolean, 29 | labels?: Record, 30 | ): JSX.Element => { 31 | if (paramType.baseType === 'address') { 32 | let address = value; 33 | let label = address; 34 | if (labels && labels[address.toLowerCase()]) { 35 | label = `[${labels[address.toLowerCase()]}]`; 36 | } 37 | 38 | label = {label}; 39 | 40 | if (makeLink) { 41 | return ( 42 | 47 | {label} 48 | 49 | ); 50 | } else { 51 | return <>{label}; 52 | } 53 | } 54 | 55 | let encoded = value; 56 | if (encoded.length > 96 && truncate) { 57 | encoded = encoded.substring(0, 8) + '...' + encoded.substring(encoded.length - 8); 58 | } 59 | return <>{encoded}; 60 | }; 61 | 62 | type DataRendererProps = { 63 | labels?: Record; 64 | data?: string; 65 | decodedData?: any; 66 | showCopy?: boolean; 67 | makeLink?: boolean; 68 | preferredType: string | ParamType | null; 69 | truncate?: boolean; 70 | }; 71 | 72 | const NoMaxWidthTooltip = styled(({ className, ...props }: TooltipProps) => ( 73 | 74 | ))({ 75 | [`& .${tooltipClasses.tooltip}`]: { 76 | maxWidth: 'none', 77 | }, 78 | }); 79 | 80 | export const DataRenderer = (props: DataRendererProps) => { 81 | const chainConfig = useContext(ChainConfigContext); 82 | const labelMetadata = useContext(LabelMetadataContext); 83 | const preimageMetadata = useContext(PreimageMetadataContext); 84 | 85 | const abiCoder = ethers.utils.defaultAbiCoder; 86 | 87 | let preferredType = props.preferredType || 'bytes32'; 88 | let decodedData = props.decodedData; 89 | let data = props.data; 90 | let makeLink = props.makeLink === undefined ? true : props.makeLink; 91 | let truncate = props.truncate; 92 | 93 | let suffix = null; 94 | if (data) { 95 | if (data.startsWith('0x')) data = data.substring(2); 96 | data = '0x' + data.padStart(64, '0'); 97 | } 98 | 99 | if (preferredType === 'stringHeader' && data) { 100 | if (BigNumber.from(data).isZero()) { 101 | preferredType = 'uint256'; 102 | decodedData = BigNumber.from(0); 103 | suffix = <> (length); 104 | } else { 105 | let lowestBit = parseInt(data.substring(data.length - 2)) & 0x01; 106 | if (lowestBit) { 107 | preferredType = 'uint256'; 108 | decodedData = BigNumber.from(data).sub(BigNumber.from(1)); 109 | suffix = <> (length); 110 | } else { 111 | preferredType = 'ascii'; 112 | data = data.substring(0, data.length - 2) + '00'; 113 | } 114 | } 115 | } 116 | 117 | if (preferredType === 'ascii' && data) { 118 | data = data.replace(/(00)+$/g, ''); 119 | return <>'{ethers.utils.toUtf8String(data)}'; 120 | } 121 | 122 | let paramType = ParamType.from(preferredType); 123 | if (paramType.type === 'contract') { 124 | paramType = ParamType.from('address'); 125 | } 126 | 127 | try { 128 | if (decodedData === undefined && data) { 129 | decodedData = abiCoder.decode([paramType], data); 130 | } 131 | 132 | let hasPreimage = false; 133 | let wasIndexed = false; 134 | // console.log(paramType, decodedData, preimageMetadata.preimages); 135 | const want = paramType.indexed && paramType.baseType !== 'bytes32' ? decodedData.hash : decodedData; 136 | if ((paramType.type === 'bytes32' || paramType.indexed) && preimageMetadata.preimages[want] !== undefined) { 137 | decodedData = preimageMetadata.preimages[want]; 138 | hasPreimage = true; 139 | wasIndexed = paramType.type !== 'bytes32' && paramType.indexed; 140 | paramType = ParamType.from('bytes'); 141 | } 142 | 143 | let stringified = stringifyValue(paramType, decodedData); 144 | 145 | let copyButton; 146 | if (props.showCopy) { 147 | copyButton = ( 148 | <> 149 | { 152 | navigator.clipboard.writeText(stringified); 153 | }} 154 | /> 155 |   156 | 157 | ); 158 | } 159 | 160 | let rendered = formatValueWithParamType( 161 | paramType, 162 | chainConfig, 163 | stringified, 164 | truncate || false, 165 | makeLink, 166 | props.labels || labelMetadata.labels, 167 | ); 168 | 169 | if (paramType.baseType === 'address') { 170 | rendered = ( 171 | { 178 | const address = stringified.toLowerCase(); 179 | 180 | let newLabel = prompt( 181 | 'Enter a new label', 182 | (props.labels || labelMetadata.labels)[address] || address, 183 | ); 184 | if (newLabel !== null && newLabel !== address) { 185 | labelMetadata.updater((prevState) => { 186 | const newState = { ...prevState }; 187 | 188 | if (!(chainConfig.id in newState.customLabels)) { 189 | newState.customLabels[chainConfig.id] = {}; 190 | } 191 | newState.labels[address] = newLabel || newState.labels[address]; 192 | newState.customLabels[chainConfig.id][address] = newLabel || ''; 193 | localStorage.setItem('pref:labels', JSON.stringify(newState.customLabels)); 194 | 195 | if (chainConfig.id === 'ethereum') { 196 | fetch(`https://tags.eth.samczsun.com/api/v1/address/${address}`, { 197 | method: 'POST', 198 | body: JSON.stringify({ 199 | label: newLabel, 200 | }), 201 | }) 202 | .then(console.log) 203 | .catch(console.log); 204 | } 205 | 206 | return newState; 207 | }); 208 | } 209 | }} 210 | > 211 | [Edit Label] 212 | 213 | } 214 | > 215 | {rendered} 216 | 217 | ); 218 | } else if (paramType.baseType === 'bytes' && hasPreimage && !wasIndexed) { 219 | rendered = ( 220 | {ethers.utils.keccak256(decodedData)}}> 221 | keccak256({rendered}) 222 | 223 | ); 224 | } 225 | 226 | // console.log(paramType, decodedData, rendered); 227 | 228 | return ( 229 | <> 230 | {copyButton} 231 | {rendered} 232 | {suffix} 233 | 234 | ); 235 | } catch (e) { 236 | console.log('failed to render', props, e); 237 | return <>{props.data}; 238 | } 239 | }; 240 | -------------------------------------------------------------------------------- /components/EncodedABITextField.tsx: -------------------------------------------------------------------------------- 1 | import { SpanIconButton } from './SpanIconButton'; 2 | import FormatClearIcon from '@mui/icons-material/FormatClear'; 3 | import FormatAlignJustifyIcon from '@mui/icons-material/FormatAlignJustify'; 4 | import { chunkString } from './helpers'; 5 | import { TextField } from '@mui/material'; 6 | import * as React from 'react'; 7 | 8 | type EncodedABITextFieldProps = { 9 | name: string; 10 | hasSelector: boolean; 11 | initialValue: string; 12 | value: string; 13 | setter: React.Dispatch>; 14 | }; 15 | 16 | export const EncodedABITextField = (props: EncodedABITextFieldProps) => { 17 | const [shouldWrap, setShouldWrap] = React.useState(true); 18 | 19 | const { name, hasSelector, initialValue, value, setter } = props; 20 | 21 | return ( 22 | <> 23 | {name}  24 | { 27 | setter(initialValue.replace(/\n/g, '')); 28 | setShouldWrap(true); 29 | }} 30 | /> 31 |   32 | { 35 | let selector = hasSelector ? initialValue.substring(0, 10) : ''; 36 | let data = hasSelector ? initialValue.substring(10) : initialValue.substring(2); 37 | let chunks = chunkString(data, 64); 38 | 39 | let maxLen = ((chunks.length - 1) * 32).toString(16).length; 40 | setter( 41 | (hasSelector ? selector + '\n' : '') + 42 | chunks 43 | .map((v, i) => '0x' + (i * 32).toString(16).padStart(maxLen, '0') + ': ' + v) 44 | .join('\n'), 45 | ); 46 | setShouldWrap(false); 47 | }} 48 | /> 49 | :
50 | setter(e.target.value)} 65 | multiline 66 | fullWidth 67 | > 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /components/FragmentTextField.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from '@mui/material'; 2 | import * as React from 'react'; 3 | 4 | type FragmentTextFieldProps = { 5 | name: string; 6 | value: string; 7 | onChange: React.Dispatch>; 8 | }; 9 | 10 | export const FragmentTextField = (props: FragmentTextFieldProps) => { 11 | const { name, value, onChange } = props; 12 | 13 | return ( 14 | <> 15 | {name}:
16 | onChange(e.target.value)} 30 | fullWidth 31 | > 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import * as React from 'react'; 3 | 4 | import { DarkMode, GitHub, LightMode, Twitter } from '@mui/icons-material'; 5 | import { 6 | Button, 7 | Container, 8 | Divider, 9 | FormControl, 10 | IconButton, 11 | Input, 12 | InputBase, 13 | InputLabel, 14 | MenuItem, 15 | NativeSelect, 16 | Paper, 17 | Select, 18 | Typography, 19 | } from '@mui/material'; 20 | import Grid2 from '@mui/material/Unstable_Grid2'; 21 | import { Box } from '@mui/system'; 22 | import Image from 'next/image'; 23 | import Link from 'next/link'; 24 | import { useRouter } from 'next/router'; 25 | import { SupportedChains } from './Chains'; 26 | import SearchIcon from '@mui/icons-material/Search'; 27 | import TextField from '@mui/material/TextField'; 28 | 29 | export type NavbarProps = { 30 | useDarkMode: boolean; 31 | onSetUseDarkMode: (v: boolean) => void; 32 | }; 33 | 34 | function Navbar(props: NavbarProps) { 35 | const router = useRouter(); 36 | const { chain: queryChain, txhash: queryTxhash } = router.query; 37 | 38 | // sets the default chain to ethereum. 39 | const [chain, setChain] = React.useState('ethereum'); 40 | const [txhash, setTxhash] = React.useState(''); 41 | 42 | React.useEffect(() => { 43 | if (!queryChain || Array.isArray(queryChain)) return; 44 | if (!queryTxhash || Array.isArray(queryTxhash)) return; 45 | 46 | setChain(queryChain); 47 | setTxhash(queryTxhash); 48 | }, [queryChain, queryTxhash]); 49 | 50 | const doSearch = () => { 51 | if (/0x[0-9a-fA-F]{64}/g.test(txhash)) { 52 | router.push(`/${chain}/${txhash}`); 53 | } 54 | }; 55 | 56 | return ( 57 |
58 | 59 | Ethereum Transaction Viewer 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | logo 80 | 81 | 82 | 83 | 84 | Ethereum Transaction Viewer 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 98 | 99 | 100 | 101 | props.onSetUseDarkMode(!props.useDarkMode)}> 102 | {props.useDarkMode ? : } 103 | 104 | 105 | 106 | 107 | 108 | setChain(event.target.value)} 110 | value={chain} 111 | variant="standard" 112 | select 113 | margin="dense" 114 | fullWidth 115 | SelectProps={{ 116 | style: { 117 | fontFamily: 'RiformaLL', 118 | }, 119 | }} 120 | > 121 | {SupportedChains.map((v) => { 122 | return ( 123 | 124 | {v.displayName} 125 | 126 | ); 127 | })} 128 | {!SupportedChains.find((sChain) => sChain.id === chain) ? ( 129 | 130 | {queryChain} 131 | 132 | ) : null} 133 | 134 | 135 | 136 | setTxhash(event.target.value)} 142 | value={txhash} 143 | onKeyUp={(event) => { 144 | if (event.key === 'Enter') { 145 | doSearch(); 146 | } 147 | }} 148 | inputProps={{ 149 | style: { 150 | fontFamily: 'RiformaLL', 151 | }, 152 | }} 153 | InputProps={{ 154 | endAdornment: ( 155 | 165 | ), 166 | }} 167 | > 168 | 169 | 170 | 171 |
172 | ); 173 | } 174 | 175 | export default Navbar; 176 | -------------------------------------------------------------------------------- /components/ParamFlatView.tsx: -------------------------------------------------------------------------------- 1 | import { TraceMetadata } from './types'; 2 | import { ParamType, Result } from '@ethersproject/abi/lib'; 3 | import WithSeparator from 'react-with-separator'; 4 | import { DataRenderer } from './DataRenderer'; 5 | import * as React from 'react'; 6 | import { Property } from 'csstype'; 7 | import Color = Property.Color; 8 | 9 | type ParamFlatViewProps = { 10 | traceMetadata: TraceMetadata; 11 | params: ParamType[]; 12 | values: Result; 13 | 14 | generateNames?: boolean; 15 | nameColor?: Color; 16 | }; 17 | 18 | export const ParamFlatView = (props: ParamFlatViewProps) => { 19 | let generateNames = props.generateNames === true; 20 | let nameColor = props.nameColor || '#a8a19f'; 21 | 22 | let recursivelyRenderParams = (params: ParamType[], values: Result): JSX.Element => { 23 | return ( 24 | , }> 25 | {params.map((param, idx) => { 26 | let value = values[idx]; 27 | 28 | let name = param.name; 29 | if (!name && generateNames) { 30 | name = `var_${idx}`; 31 | } 32 | 33 | let rendered: JSX.Element; 34 | if (param.baseType === 'tuple') { 35 | rendered = <>({recursivelyRenderParams(param.components, value)}); 36 | } else if (param.baseType === 'array') { 37 | rendered = ( 38 | <>[{recursivelyRenderParams(Array(value.length).fill(param.arrayChildren), value)}] 39 | ); 40 | } else { 41 | rendered = ( 42 | 43 | ); 44 | } 45 | 46 | if (name) { 47 | return ( 48 | 49 | {name}={rendered} 50 | 51 | ); 52 | } else { 53 | return {rendered}; 54 | } 55 | })} 56 | 57 | ); 58 | }; 59 | 60 | return <>{recursivelyRenderParams(props.params, props.values)}; 61 | }; 62 | -------------------------------------------------------------------------------- /components/ParamTreeView.tsx: -------------------------------------------------------------------------------- 1 | import { TraceMetadata } from './types'; 2 | import { ParamType, Result } from '@ethersproject/abi/lib'; 3 | import { TreeItemContentSpan } from './helpers'; 4 | import { DataRenderer } from './DataRenderer'; 5 | import TreeItem from '@mui/lab/TreeItem'; 6 | import TreeView from '@mui/lab/TreeView'; 7 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 8 | import ChevronRightIcon from '@mui/icons-material/ChevronRight'; 9 | import * as React from 'react'; 10 | 11 | type ParamTreeViewProps = { 12 | traceMetadata: TraceMetadata; 13 | path: string; 14 | params: ParamType[]; 15 | values: Result; 16 | }; 17 | 18 | export const ParamTreeView = (props: ParamTreeViewProps) => { 19 | let recursivelyRenderParams = (path: string, params: ParamType[], values: Result): JSX.Element[] => { 20 | return params.map((param, idx) => { 21 | let paramName = param.name || `var_${idx}`; 22 | 23 | let nodeId = path + '.' + idx; 24 | let value = values[idx]; 25 | 26 | let label: JSX.Element; 27 | let children: JSX.Element[]; 28 | if (param.baseType === 'tuple') { 29 | label = <>{paramName}; 30 | children = value.map((childValue: any, childIdx: number) => { 31 | return recursivelyRenderParams(nodeId + '.' + childIdx, [param.components[childIdx]], [childValue]); 32 | }); 33 | } else if (param.baseType === 'array') { 34 | label = <>{paramName}; 35 | children = value.map((childValue: any, childIdx: number) => { 36 | let paramJson = JSON.parse(param.arrayChildren.format('json')); 37 | paramJson.name = paramName + `[${childIdx}]`; 38 | return recursivelyRenderParams(nodeId + '.' + childIdx, [ParamType.from(paramJson)], [childValue]); 39 | }); 40 | } else { 41 | label = ( 42 | <> 43 | {paramName}:  44 | 45 | 46 | ); 47 | children = []; 48 | } 49 | 50 | return ( 51 | {label}}> 52 | {children} 53 | 54 | ); 55 | }); 56 | }; 57 | 58 | return ( 59 | } 62 | defaultExpanded={['root']} 63 | defaultExpandIcon={} 64 | sx={{ 65 | paddingBottom: '20px', 66 | }} 67 | > 68 | {recursivelyRenderParams(props.path + '.root', props.params, props.values)} 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /components/SpanIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIconProps } from '@mui/material'; 2 | import * as React from 'react'; 3 | 4 | type SpanIconButtonProps = { 5 | icon: React.JSXElementConstructor; 6 | onClick: React.MouseEventHandler; 7 | }; 8 | 9 | export const SpanIconButton = (props: SpanIconButtonProps) => { 10 | return ( 11 | 12 | [ 13 | 14 | 15 | 16 | ] 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /components/api.tsx: -------------------------------------------------------------------------------- 1 | import { JsonFragment } from '@ethersproject/abi'; 2 | 3 | export type AddressInfo = { 4 | label: string; 5 | functions: Record; 6 | events: Record; 7 | errors: Record; 8 | }; 9 | export type TraceEntryCall = { 10 | path: string; 11 | type: 'call'; 12 | variant: 'call' | 'callcode' | 'staticcall' | 'delegatecall' | 'create' | 'create2' | 'selfdestruct'; 13 | gas: number; 14 | isPrecompile: boolean; 15 | from: string; 16 | to: string; 17 | input: string; 18 | output: string; 19 | gasUsed: number; 20 | value: string; 21 | status: number; 22 | 23 | codehash: string; 24 | 25 | children: TraceEntry[]; 26 | }; 27 | export type TraceEntryLog = { 28 | path: string; 29 | type: 'log'; 30 | topics: string[]; 31 | data: string; 32 | }; 33 | export type TraceEntrySload = { 34 | path: string; 35 | type: 'sload'; 36 | slot: string; 37 | value: string; 38 | }; 39 | export type TraceEntrySstore = { 40 | path: string; 41 | type: 'sstore'; 42 | slot: string; 43 | oldValue: string; 44 | newValue: string; 45 | }; 46 | export type TraceEntry = TraceEntryCall | TraceEntryLog | TraceEntrySload | TraceEntrySstore; 47 | export type TraceResponse = { 48 | chain: string; 49 | txhash: string; 50 | preimages: Record; 51 | addresses: Record>; 52 | entrypoint: TraceEntryCall; 53 | }; 54 | 55 | export type StorageResponse = { 56 | allStructs: any[]; 57 | arrays: any[]; 58 | structs: any[]; 59 | slots: Record; 60 | }; 61 | 62 | export function apiEndpoint() { 63 | return process.env.NEXT_PUBLIC_API_HOST || 'https://tx.eth.samczsun.com'; 64 | } 65 | 66 | export type APIResponseError = { 67 | ok: false; 68 | error: string; 69 | }; 70 | export type APIResponseSuccess = { 71 | ok: true; 72 | result: T; 73 | }; 74 | export type APIResponse = APIResponseError | APIResponseSuccess; 75 | export const doApiRequest = async (path: string, init?: RequestInit): Promise => { 76 | return fetch(`${apiEndpoint()}${path}`, init) 77 | .then((res) => res.json()) 78 | .then((json) => json as APIResponse) 79 | .then((resp) => { 80 | if (!resp.ok) { 81 | throw new Error(resp.error); 82 | } 83 | return resp.result; 84 | }); 85 | }; 86 | -------------------------------------------------------------------------------- /components/decoder-format/DecodeTree.tsx: -------------------------------------------------------------------------------- 1 | import { Interface } from '@ethersproject/abi'; 2 | import { Log } from '@ethersproject/abstract-provider'; 3 | import { BaseProvider } from '@ethersproject/providers'; 4 | import ChevronRightIcon from '@mui/icons-material/ChevronRight'; 5 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 6 | import TreeView from '@mui/lab/TreeView'; 7 | import * as ethers from 'ethers'; 8 | import { BigNumber } from 'ethers'; 9 | import * as React from 'react'; 10 | import { useContext } from 'react'; 11 | import { ChainConfigContext } from '../Chains'; 12 | import { findAffectedContract } from '../helpers'; 13 | import { fetchDefiLlamaPrices, PriceMetadataContext } from '../metadata/prices'; 14 | import { fetchTokenMetadata, TokenMetadataContext } from '../metadata/tokens'; 15 | import { TransactionMetadataContext } from '../metadata/transaction'; 16 | import { TraceTreeItem } from '../trace/TraceTreeItem'; 17 | import { TraceMetadata } from '../types'; 18 | import { TraceEntryCall, TraceEntryLog, TraceResponse } from '../api'; 19 | import { format } from './formatter'; 20 | import { defaultDecoders } from '@samczsun/transaction-decoder/lib/decoders'; 21 | import { TransferDecoder } from '@samczsun/transaction-decoder/lib/decoders/fallback'; 22 | import { DecoderManager } from '@samczsun/transaction-decoder/lib/sdk/decoder'; 23 | import { getNodeId } from '@samczsun/transaction-decoder/lib/sdk/utils'; 24 | import { 25 | DecoderOutput, 26 | MetadataRequest, 27 | ProviderDecoderChainAccess, 28 | DecoderInputTraceExt, 29 | } from '@samczsun/transaction-decoder/lib/sdk/types'; 30 | 31 | const decoderManager = new DecoderManager(defaultDecoders, new TransferDecoder()); 32 | 33 | export type DecodeTreeProps = { 34 | provider: BaseProvider; 35 | traceResult: TraceResponse; 36 | traceMetadata: TraceMetadata; 37 | }; 38 | 39 | export const DecodeTree = (props: DecodeTreeProps) => { 40 | const priceMetadata = useContext(PriceMetadataContext); 41 | const tokenMetadata = useContext(TokenMetadataContext); 42 | const transactionMetadata = useContext(TransactionMetadataContext); 43 | const chainConfig = useContext(ChainConfigContext); 44 | 45 | const [data, setData] = React.useState<[DecoderOutput, MetadataRequest]>(); 46 | 47 | React.useEffect(() => { 48 | const access = new ProviderDecoderChainAccess(props.provider); 49 | 50 | let logIndex = 0; 51 | let indexToPath: Record = {}; 52 | 53 | const flattenLogs = (node: TraceEntryCall, recursive: boolean): Array => { 54 | const ourLogs = node.children 55 | .filter((node): node is TraceEntryLog => node.type === 'log') 56 | .map((logNode) => { 57 | const [affected] = findAffectedContract(props.traceMetadata, logNode); 58 | indexToPath[logIndex] = logNode.path; 59 | const log: Log = { 60 | address: ethers.utils.getAddress(affected.to), 61 | blockHash: '', 62 | blockNumber: 0, 63 | data: logNode.data, 64 | logIndex: logNode.path, 65 | removed: false, 66 | topics: logNode.topics, 67 | transactionHash: props.traceResult.txhash, 68 | transactionIndex: 0, 69 | }; 70 | return log; 71 | }); 72 | if (!recursive) { 73 | return ourLogs; 74 | } 75 | 76 | node.children 77 | .filter((node): node is TraceEntryCall => node.type === 'call') 78 | .forEach((v) => { 79 | ourLogs.push(...flattenLogs(v, true)); 80 | }); 81 | 82 | return ourLogs; 83 | }; 84 | 85 | const remap = (node: TraceEntryCall, parentAbi?: Interface): DecoderInputTraceExt => { 86 | let thisAbi = new Interface([ 87 | ...props.traceMetadata.abis[node.to][node.codehash].fragments, 88 | ...(parentAbi?.fragments || []), 89 | ]); 90 | 91 | const logs = flattenLogs(node, false); 92 | const children = node.children 93 | .filter((node): node is TraceEntryCall => node.type === 'call') 94 | .map((v) => { 95 | if (v.variant === 'delegatecall') { 96 | return remap(v, thisAbi); 97 | } else { 98 | return remap(v, undefined); 99 | } 100 | }); 101 | 102 | return { 103 | id: node.path, 104 | type: node.variant, 105 | from: ethers.utils.getAddress(node.from), 106 | to: ethers.utils.getAddress(node.to), 107 | value: BigNumber.from(node.value), 108 | calldata: ethers.utils.arrayify(node.input), 109 | 110 | failed: node.status !== 1, 111 | logs: logs, 112 | 113 | returndata: ethers.utils.arrayify(node.output), 114 | children: children, 115 | 116 | childOrder: node.children 117 | .filter( 118 | (node): node is TraceEntryLog | TraceEntryCall => node.type === 'log' || node.type === 'call', 119 | ) 120 | .map((v) => { 121 | if (v.type === 'log') { 122 | return ['log', logs.findIndex((log) => log.logIndex === v.path)]; 123 | } else { 124 | return ['call', children.findIndex((child) => child.id === v.path)]; 125 | } 126 | }), 127 | 128 | abi: thisAbi, 129 | }; 130 | }; 131 | 132 | const input = remap(props.traceResult.entrypoint); 133 | console.log('remapped input', input); 134 | decoderManager.decode(input, access).then((data) => { 135 | console.log('decoded output', data); 136 | setData(data); 137 | }); 138 | }, [props.traceResult, props.traceMetadata]); 139 | 140 | let children; 141 | 142 | if (data) { 143 | const [decodedActions, requestedMetadata] = data; 144 | 145 | if (transactionMetadata.result) { 146 | fetchDefiLlamaPrices( 147 | priceMetadata.updater, 148 | Array.from(requestedMetadata.tokens).map((token) => `${chainConfig.defillamaPrefix}:${token}`), 149 | transactionMetadata.result.timestamp, 150 | ); 151 | } 152 | 153 | fetchTokenMetadata(tokenMetadata.updater, props.provider, Array.from(requestedMetadata.tokens)); 154 | 155 | const recursivelyGenerateTree = (node: DecoderOutput): JSX.Element[] => { 156 | let results: JSX.Element[] = []; 157 | if (node.children) { 158 | for (let child of node.children) { 159 | results.push(...recursivelyGenerateTree(child)); 160 | } 161 | } 162 | if (node.results.length === 0) { 163 | return results; 164 | } 165 | 166 | return node.results.map((v, i) => { 167 | let id = getNodeId(node.node) + '.result_' + i; 168 | return ( 169 | 179 | {results} 180 | 181 | ); 182 | }); 183 | }; 184 | 185 | try { 186 | children = recursivelyGenerateTree(decodedActions); 187 | } catch (e) { 188 | console.log('failed to generate decoded tree!', e); 189 | } 190 | } 191 | 192 | return ( 193 | <> 194 | } 197 | defaultExpandIcon={} 198 | > 199 | {children} 200 | 201 | 202 | ); 203 | }; 204 | -------------------------------------------------------------------------------- /components/decoder-format/ens.tsx: -------------------------------------------------------------------------------- 1 | import Tooltip from '@mui/material/Tooltip'; 2 | import humanizeDuration from 'humanize-duration'; 3 | import { DateTime } from 'luxon'; 4 | import { ENSRegisterAction, NATIVE_TOKEN } from '@samczsun/transaction-decoder/lib/sdk/actions'; 5 | import { DecodeFormatOpts, Formatter } from './types'; 6 | 7 | export class ENSFormatter extends Formatter { 8 | format(result: ENSRegisterAction, opts: DecodeFormatOpts): JSX.Element { 9 | const keys = ['name', 'owner', 'expiry', 'cost']; 10 | const vals = [ 11 | result.name, 12 | this.formatAddress(result.owner), 13 | 14 | 15 | {DateTime.fromSeconds(opts.timestamp + result.duration).toFormat('yyyy-MM-dd hh:mm:ss ZZZZ')} 16 | 17 | , 18 | this.formatTokenAmount(opts, NATIVE_TOKEN, result.cost), 19 | ]; 20 | 21 | if (result.resolver !== undefined && result.resolver !== '0x0000000000000000000000000000000000000000') { 22 | keys.push('resolver'); 23 | vals.push(this.formatAddress(result.resolver)); 24 | } 25 | 26 | if (result.addr !== undefined && result.addr !== '0x0000000000000000000000000000000000000000') { 27 | keys.push('addr'); 28 | vals.push(this.formatAddress(result.addr)); 29 | } 30 | 31 | keys.push('operator'); 32 | vals.push(this.formatAddress(result.operator)); 33 | 34 | return this.renderResult('register ens', 'ffffff', keys, vals); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /components/decoder-format/formatter.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@samczsun/transaction-decoder/lib/sdk/actions'; 2 | import { ENSFormatter } from './ens'; 3 | import { SwapFormatter } from './swap'; 4 | import { TransferFormatter } from './transfer'; 5 | import { DecodeFormatOpts, Formatter } from './types'; 6 | import { WrappedNativeTokenFormatter } from './wrapped'; 7 | 8 | const allFormatters: Record> = { 9 | swap: new SwapFormatter(), 10 | 'ens-register': new ENSFormatter(), 11 | 'mint-erc20': new TransferFormatter(), 12 | 'burn-erc20': new TransferFormatter(), 13 | transfer: new TransferFormatter(), 14 | 'wrap-native-token': new WrappedNativeTokenFormatter(), 15 | 'unwrap-native-token': new WrappedNativeTokenFormatter(), 16 | }; 17 | 18 | export const format = (result: Action, opts: DecodeFormatOpts): JSX.Element => { 19 | return allFormatters[result.type].format(result, opts); 20 | }; 21 | -------------------------------------------------------------------------------- /components/decoder-format/swap.tsx: -------------------------------------------------------------------------------- 1 | import { DataRenderer } from '../DataRenderer'; 2 | import { SwapAction } from '@samczsun/transaction-decoder/lib/sdk/actions'; 3 | import { DecodeFormatOpts, Formatter } from './types'; 4 | 5 | export class SwapFormatter extends Formatter { 6 | format(result: SwapAction, opts: DecodeFormatOpts): JSX.Element { 7 | const keys = []; 8 | const values = []; 9 | 10 | keys.push('exchange'); 11 | values.push(result.exchange); 12 | 13 | if (result.amountIn !== undefined) { 14 | keys.push('tokenIn'); 15 | values.push(this.formatTokenAmount(opts, result.tokenIn, result.amountIn)); 16 | } else if (result.amountInMax !== undefined) { 17 | keys.push('tokenInMax'); 18 | values.push(this.formatTokenAmount(opts, result.tokenIn, result.amountInMax)); 19 | } 20 | 21 | if (result.amountOut !== undefined) { 22 | keys.push('amountOut'); 23 | values.push(this.formatTokenAmount(opts, result.tokenOut, result.amountOut)); 24 | } else if (result.amountOutMin !== undefined) { 25 | keys.push('amountOutMin'); 26 | values.push(this.formatTokenAmount(opts, result.tokenOut, result.amountOutMin)); 27 | } 28 | 29 | keys.push('recipient'); 30 | values.push(); 31 | 32 | keys.push('actor'); 33 | values.push(); 34 | 35 | return this.renderResult('swap', '#645e9d', keys, values); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /components/decoder-format/transfer.tsx: -------------------------------------------------------------------------------- 1 | import { DataRenderer } from '../DataRenderer'; 2 | import { BurnERC20Action, MintERC20Action, TransferAction } from '@samczsun/transaction-decoder/lib/sdk/actions'; 3 | import { DecodeFormatOpts, Formatter } from './types'; 4 | 5 | export class TransferFormatter extends Formatter { 6 | format(result: MintERC20Action | BurnERC20Action | TransferAction, opts: DecodeFormatOpts): JSX.Element { 7 | switch (result.type) { 8 | case 'mint-erc20': 9 | return this.renderResult( 10 | 'mint', 11 | '#392b58', 12 | [opts.tokens.tokens[result.token.toLowerCase()]?.isNft ? 'id' : 'amount', 'to', 'operator'], 13 | [ 14 | this.formatTokenAmount(opts, result.token, result.amount), 15 | , 16 | , 17 | ], 18 | ); 19 | case 'burn-erc20': 20 | return this.renderResult( 21 | 'burn', 22 | '#392b58', 23 | [opts.tokens.tokens[result.token.toLowerCase()]?.isNft ? 'id' : 'amount', 'from', 'operator'], 24 | [ 25 | this.formatTokenAmount(opts, result.token, result.amount), 26 | , 27 | , 28 | ], 29 | ); 30 | case 'transfer': 31 | return this.renderResult( 32 | 'transfer', 33 | '#392b58', 34 | [opts.tokens.tokens[result.token.toLowerCase()]?.isNft ? 'id' : 'amount', 'from', 'to', 'operator'], 35 | [ 36 | this.formatTokenAmount(opts, result.token, result.amount), 37 | , 38 | , 39 | , 40 | ], 41 | ); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /components/decoder-format/types.tsx: -------------------------------------------------------------------------------- 1 | import Tooltip from '@mui/material/Tooltip'; 2 | import { NATIVE_TOKEN } from '@samczsun/transaction-decoder/lib/sdk/actions'; 3 | import { BigNumber, BigNumberish, ethers } from 'ethers'; 4 | import React from 'react'; 5 | import WithSeparator from 'react-with-separator'; 6 | import { ChainConfig } from '../Chains'; 7 | import { DataRenderer } from '../DataRenderer'; 8 | import { formatUsd } from '../helpers'; 9 | import { PriceMetadata } from '../metadata/prices'; 10 | import { TokenMetadata } from '../metadata/tokens'; 11 | import { TraceTreeNodeLabel } from '../trace/TraceTreeItem'; 12 | 13 | export type DecodeFormatOpts = { 14 | timestamp: number; 15 | chain: ChainConfig; 16 | prices: PriceMetadata; 17 | tokens: TokenMetadata; 18 | }; 19 | 20 | export abstract class Formatter { 21 | abstract format(result: T, opts: DecodeFormatOpts): JSX.Element; 22 | 23 | formatAddress(addr: string): JSX.Element { 24 | return ; 25 | } 26 | 27 | formatTokenAmount(opts: DecodeFormatOpts, token: string, amount: BigNumberish): JSX.Element { 28 | token = token.toLowerCase(); 29 | if (token === NATIVE_TOKEN) { 30 | token = opts.chain.nativeTokenAddress || ''; 31 | } 32 | 33 | let amountFormatted = amount.toString(); 34 | let address = ; 35 | let price; 36 | 37 | let tokenInfo = opts.tokens.tokens[token]; 38 | if (tokenInfo !== undefined) { 39 | if (tokenInfo.decimals !== undefined) { 40 | amountFormatted = ethers.utils.formatUnits(amount, tokenInfo.decimals); 41 | } 42 | if (tokenInfo.symbol !== undefined) { 43 | address = ( 44 | 45 | ); 46 | } 47 | } 48 | 49 | let historicalPrice = opts.prices.prices[token]?.historicalPrice; 50 | let currentPrice = opts.prices.prices[token]?.currentPrice; 51 | if (historicalPrice !== undefined && currentPrice !== undefined) { 52 | price = ( 53 | <> 54 |  ( 55 | 62 | {formatUsd(BigNumber.from(amount).mul(historicalPrice))} 63 | 64 | ) 65 | 66 | ); 67 | } 68 | 69 | return ( 70 | <> 71 | {amountFormatted} {address} 72 | {price} 73 | 74 | ); 75 | } 76 | 77 | renderResult(nodeType: string, nodeColor: string, keys: string[], values: any[]) { 78 | return ( 79 | <> 80 | 81 |   82 | , }> 83 | {keys.map((key, idx) => { 84 | return ( 85 | 86 | {key}={values[idx]} 87 | 88 | ); 89 | })} 90 | 91 | 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /components/decoder-format/wrapped.tsx: -------------------------------------------------------------------------------- 1 | import { UnwrapNativeTokenAction, WrapNativeTokenAction } from '@samczsun/transaction-decoder/lib/sdk/actions'; 2 | import { DataRenderer } from '../DataRenderer'; 3 | import { DecodeFormatOpts, Formatter } from './types'; 4 | 5 | export class WrappedNativeTokenFormatter extends Formatter { 6 | format(result: WrapNativeTokenAction | UnwrapNativeTokenAction, opts: DecodeFormatOpts): JSX.Element { 7 | if (result.type === 'wrap-native-token') { 8 | return this.renderResult( 9 | 'wrap', 10 | '#392b58', 11 | [opts.tokens.tokens[result.token.toLowerCase()]?.isNft ? 'id' : 'amount', 'operator'], 12 | [ 13 | this.formatTokenAmount(opts, result.token, result.amount), 14 | , 15 | ], 16 | ); 17 | } else { 18 | return this.renderResult( 19 | 'unwrap', 20 | '#392b58', 21 | [opts.tokens.tokens[result.token.toLowerCase()]?.isNft ? 'id' : 'amount', 'operator'], 22 | [ 23 | this.formatTokenAmount(opts, result.token, result.amount), 24 | , 25 | ], 26 | ); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /components/ethers/json-rpc-batch-provider.ts: -------------------------------------------------------------------------------- 1 | import { deepCopy } from '@ethersproject/properties'; 2 | import { fetchJson } from '@ethersproject/web'; 3 | 4 | import { StaticJsonRpcProvider } from '@ethersproject/providers'; 5 | import { ConnectionInfo } from 'ethers/lib/utils'; 6 | import { Networkish } from '@ethersproject/networks'; 7 | 8 | // Experimental 9 | 10 | interface RpcResult { 11 | jsonrpc: '2.0'; 12 | id: number; 13 | result?: string; 14 | error?: { 15 | code: number; 16 | message: string; 17 | data?: any; 18 | }; 19 | } 20 | 21 | export class JsonRpcBatchProvider extends StaticJsonRpcProvider { 22 | _pendingBatchAggregator?: NodeJS.Timer; 23 | _pendingBatch?: Array<{ 24 | request: { method: string; params: Array; id: number; jsonrpc: '2.0' }; 25 | resolve: (result: any) => void; 26 | reject: (error: Error) => void; 27 | }>; 28 | 29 | constructor(url?: ConnectionInfo | string, network?: Networkish) { 30 | super(url, network); 31 | } 32 | 33 | send(method: string, params: Array): Promise { 34 | const request = { 35 | method: method, 36 | params: params, 37 | id: this._nextId++, 38 | jsonrpc: '2.0', 39 | }; 40 | 41 | if (this._pendingBatch == null) { 42 | this._pendingBatch = []; 43 | } 44 | 45 | const inflightRequest: any = { request, resolve: null, reject: null }; 46 | 47 | const promise = new Promise((resolve, reject) => { 48 | inflightRequest.resolve = resolve; 49 | inflightRequest.reject = reject; 50 | }); 51 | 52 | this._pendingBatch.push(inflightRequest); 53 | 54 | if (!this._pendingBatchAggregator) { 55 | // Schedule batch for next event loop + short duration 56 | this._pendingBatchAggregator = setTimeout(() => this.pumpBatch(), 10); 57 | } 58 | 59 | return promise; 60 | } 61 | 62 | pumpBatch() { 63 | if (!this._pendingBatch) return; 64 | 65 | // Get the current batch and clear it, so new requests 66 | // go into the next batch 67 | const batch = this._pendingBatch.slice(0, 100); 68 | this._pendingBatch = this._pendingBatch.slice(100); 69 | this._pendingBatchAggregator = undefined; 70 | 71 | if (this._pendingBatch.length > 0) { 72 | this._pendingBatchAggregator = setTimeout(() => this.pumpBatch(), 0); 73 | } 74 | 75 | // Get the request as an array of requests 76 | const request = batch.map((inflight) => inflight.request); 77 | 78 | this.emit('debug', { 79 | action: 'requestBatch', 80 | request: deepCopy(request), 81 | provider: this, 82 | }); 83 | 84 | return fetchJson(this.connection, JSON.stringify(request)).then( 85 | (result: RpcResult[]) => { 86 | this.emit('debug', { 87 | action: 'response', 88 | request: request, 89 | response: result, 90 | provider: this, 91 | }); 92 | 93 | const resultMap = result.reduce((resultMap, payload) => { 94 | resultMap[payload.id] = payload; 95 | return resultMap; 96 | }, {} as Record); 97 | 98 | // For each result, feed it to the correct Promise, depending 99 | // on whether it was a success or error 100 | batch.forEach((inflightRequest) => { 101 | const payload = resultMap[inflightRequest.request.id]; 102 | if (payload.error) { 103 | const error = new Error(payload.error.message); 104 | (error).code = payload.error.code; 105 | (error).data = payload.error.data; 106 | inflightRequest.reject(error); 107 | } else { 108 | inflightRequest.resolve(payload.result); 109 | } 110 | }); 111 | }, 112 | (error) => { 113 | this.emit('debug', { 114 | action: 'response', 115 | error: error, 116 | request: request, 117 | provider: this, 118 | }); 119 | 120 | batch.forEach((inflightRequest) => { 121 | inflightRequest.reject(error); 122 | }); 123 | }, 124 | ); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /components/gas-price-estimator/estimate.ts: -------------------------------------------------------------------------------- 1 | import { BlockWithTransactions, TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider'; 2 | import { JsonRpcProvider } from '@ethersproject/providers'; 3 | 4 | // https://stackoverflow.com/questions/20811131/javascript-remove-outlier-from-an-array 5 | function filterOutliers(someArray: number[]) { 6 | // Copy the values, rather than operating on references to existing values 7 | var values = someArray.concat(); 8 | 9 | // Then sort 10 | values.sort(function (a, b) { 11 | return a - b; 12 | }); 13 | 14 | /* Then find a generous IQR. This is generous because if (values.length / 4) 15 | * is not an int, then really you should average the two elements on either 16 | * side to find q1. 17 | */ 18 | var q1 = values[Math.floor(values.length / 4)]; 19 | // Likewise for q3. 20 | var q3 = values[Math.ceil(values.length * (3 / 4))]; 21 | var iqr = q3 - q1; 22 | 23 | // Then find min and max values 24 | var maxValue = q3 + iqr * 1.5; 25 | var minValue = q1 - iqr * 1.5; 26 | 27 | // Then filter anything beyond or beneath these values. 28 | var filteredValues = values.filter(function (x) { 29 | return x <= maxValue && x >= minValue; 30 | }); 31 | 32 | // Then return 33 | return filteredValues; 34 | } 35 | 36 | // https://github.com/ethereum/go-ethereum/blob/6d55908347cac7463dd6a2cb236f30ec26c9a121/consensus/misc/eip1559.go#L55 37 | const calculateNextBaseFee = (gasUsed: number, gasLimit: number, baseFee: number): number => { 38 | const gasTarget = Math.floor(gasLimit / 2); 39 | 40 | if (gasUsed > gasTarget) { 41 | return baseFee + Math.max(1, ((gasUsed - gasTarget) * baseFee) / gasTarget / 8); 42 | } else if (gasUsed < gasTarget) { 43 | return Math.max(0, baseFee - ((gasTarget - gasUsed) * baseFee) / gasTarget / 8); 44 | } else { 45 | return baseFee; 46 | } 47 | }; 48 | 49 | type BlockAnalysis = { 50 | blockNumber: number; 51 | baseFee: bigint | undefined; 52 | acceptedGasPrices: number[]; 53 | }; 54 | 55 | export class GasPriceEstimator { 56 | private provider: JsonRpcProvider; 57 | private listener: (blockNumber: number) => void; 58 | 59 | private lastBaseFee: [number, number] | null = null; 60 | private analysis: BlockAnalysis[] = []; 61 | 62 | private tick: () => void = () => {}; 63 | 64 | private state: boolean = false; 65 | 66 | private useFeeHistory: boolean = false; 67 | 68 | private feeInfos: FeeInfo[] = []; 69 | 70 | constructor(provider: JsonRpcProvider) { 71 | this.provider = provider; 72 | this.listener = this.onNewBlock.bind(this); 73 | } 74 | 75 | private processBlock(block: BlockWithTransactions) { 76 | if (block.baseFeePerGas) { 77 | if (!this.lastBaseFee || block.number > this.lastBaseFee[0]) { 78 | this.lastBaseFee = [block.number, block.baseFeePerGas?.toNumber()]; 79 | } 80 | } 81 | 82 | if (block.transactions.length === 0) return; 83 | 84 | const transactionsByHash: Record = block.transactions.reduce( 85 | (v, tx) => ({ ...v, [tx.hash]: tx }), 86 | {}, 87 | ); 88 | 89 | Promise.allSettled(block.transactions.map((tx) => this.provider.getTransactionReceipt(tx.hash))) 90 | .then((results) => 91 | results 92 | .filter( 93 | (result): result is PromiseFulfilledResult => result.status === 'fulfilled', 94 | ) 95 | .map((result) => result.value) 96 | .filter((result) => result), 97 | ) 98 | .then((receipts) => { 99 | const prices = receipts 100 | .map((receipt) => 101 | (receipt.effectiveGasPrice || transactionsByHash[receipt.transactionHash].gasPrice!).toNumber(), 102 | ) 103 | .sort((a, b) => b - a); 104 | 105 | this.analysis.push({ 106 | blockNumber: block.number, 107 | baseFee: block.baseFeePerGas?.toBigInt(), 108 | acceptedGasPrices: prices, 109 | }); 110 | 111 | this.analysis.sort((a, b) => a.blockNumber - b.blockNumber); 112 | this.analysis = this.analysis.slice(0, 64); 113 | 114 | this.tick(); 115 | }); 116 | } 117 | 118 | public estimate(transaction: TransactionResponse): ['below_base_fee' | 'below_worst_tx' | null, number] { 119 | console.log('fee infos', this.feeInfos); 120 | 121 | const newestFeeInfo = this.feeInfos[0]; 122 | 123 | let maxGasPrice: number = 0; 124 | if (newestFeeInfo.baseFee) { 125 | if (transaction.maxPriorityFeePerGas && transaction.maxFeePerGas) { 126 | maxGasPrice = Math.min( 127 | transaction.maxFeePerGas.toNumber(), 128 | newestFeeInfo.baseFee + transaction.maxPriorityFeePerGas.toNumber(), 129 | ); 130 | } 131 | } 132 | if (!maxGasPrice && transaction.gasPrice) { 133 | maxGasPrice = transaction.gasPrice.toNumber(); 134 | } 135 | 136 | if (newestFeeInfo.baseFee && maxGasPrice < newestFeeInfo.baseFee) { 137 | return ['below_base_fee', -1]; 138 | } 139 | 140 | const total = this.feeInfos 141 | .map((feeInfo) => { 142 | // const weight = this.analysis.length - idx; 143 | 144 | // if (maxGasPrice > analysis.acceptedGasPrices[0]) { 145 | // return [weight, weight]; 146 | // } 147 | 148 | // const noOutliers = filterOutliers(analysis.acceptedGasPrices).sort((a, b) => b - a); 149 | 150 | // const highestPrice = noOutliers[0]; 151 | // const lowestPrice = noOutliers[noOutliers.length - 1]; 152 | 153 | // const positionInBlock = (maxGasPrice - lowestPrice) / (highestPrice - lowestPrice); 154 | // console.log("params are", Number(maxGasPrice) / 1e9, Number(lowestPrice) / 1e9, Number(highestPrice) / 1e9, positionInBlock); 155 | 156 | // let probability = positionInBlock / 0.7; 157 | // if (probability < 0) probability = 0; 158 | // if (probability > 1) probability = 1; 159 | 160 | // return [probability * weight, weight]; 161 | 162 | if (maxGasPrice < feeInfo.rewards[0]) { 163 | return 0; 164 | } else if (maxGasPrice > feeInfo.rewards[2]) { 165 | return 1; 166 | } 167 | 168 | if (maxGasPrice < feeInfo.rewards[1]) { 169 | return ((maxGasPrice - feeInfo.rewards[0]) / (feeInfo.rewards[1] - feeInfo.rewards[0])) * 0.7; 170 | } else { 171 | return 0.7 + ((maxGasPrice - feeInfo.rewards[1]) / (feeInfo.rewards[2] - feeInfo.rewards[1])) * 0.3; 172 | } 173 | }) 174 | .reduce((p, v) => p + v, 0); 175 | 176 | if (total === 0) { 177 | return ['below_worst_tx', -1]; 178 | } 179 | 180 | console.log('total is', total); 181 | const probability = total / this.feeInfos.length; 182 | 183 | return [null, 1 / probability]; 184 | } 185 | 186 | public start(tick: () => void) { 187 | this.tick = tick; 188 | 189 | if (this.state) return; 190 | this.state = true; 191 | 192 | console.log('starting estimator'); 193 | 194 | this.fetchFeeHistory() 195 | .then(() => { 196 | this.useFeeHistory = true; 197 | console.log('we can use fee history!'); 198 | }) 199 | .catch((e) => { 200 | this.useFeeHistory = false; 201 | console.log("we can't use fee history!", e); 202 | 203 | this.fetchBlockHistory(); 204 | }); 205 | 206 | this.provider.addListener('block', this.listener); 207 | } 208 | 209 | public stop() { 210 | if (!this.state) return; 211 | this.state = false; 212 | 213 | this.tick = () => {}; 214 | this.provider.removeListener('block', this.listener); 215 | } 216 | 217 | private onNewBlock(blockNumber: number) { 218 | if (this.useFeeHistory) { 219 | this.fetchFeeHistory(); 220 | } else { 221 | this.provider 222 | .getBlockWithTransactions(blockNumber) 223 | .then((result) => this.handleBlocks([result])) 224 | .catch(() => {}); 225 | } 226 | } 227 | 228 | private async fetchFeeHistory(): Promise { 229 | const feeHistory: FeeHistoryResponseRaw = await this.provider.send('eth_feeHistory', [ 230 | 16, 231 | 'latest', 232 | [1, 50, 99], 233 | ]); 234 | 235 | const response: FeeInfo[] = []; 236 | const oldestBlock = parseInt(feeHistory.oldestBlock, 16); 237 | for (let i = 0; i < 16; i++) { 238 | response.push({ 239 | blockNumber: oldestBlock + i, 240 | baseFee: parseInt(feeHistory.baseFeePerGas[i], 16), 241 | gasUsedRatio: feeHistory.gasUsedRatio[i], 242 | rewards: feeHistory.reward[i].map((v) => parseInt(v, 16)), 243 | }); 244 | } 245 | 246 | this.processFeeInfos(response, true); 247 | } 248 | 249 | private async fetchBlockHistory(): Promise { 250 | const blockNumber = await this.provider.getBlockNumber(); 251 | 252 | const promises: Promise[] = []; 253 | for (let i = 0; i < 16; i++) { 254 | promises.push(this.provider.getBlockWithTransactions(blockNumber - i)); 255 | } 256 | 257 | const promiseResults = await Promise.allSettled(promises); 258 | 259 | const blocks = promiseResults 260 | .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') 261 | .map((result) => result.value); 262 | 263 | this.handleBlocks(blocks); 264 | } 265 | 266 | private async handleBlocks(blocks: BlockWithTransactions[]) { 267 | const receiptPromises: Promise[][] = []; 268 | for (const block of blocks) { 269 | receiptPromises.push(block.transactions.map((tx) => this.provider.getTransactionReceipt(tx.hash))); 270 | } 271 | 272 | const feeInfos: FeeInfo[] = []; 273 | for (let i = 0; i < blocks.length; i++) { 274 | const block = blocks[i]; 275 | const receiptResults = await Promise.allSettled(receiptPromises[i]); 276 | 277 | const receipts = receiptResults 278 | .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') 279 | .map((result) => result.value); 280 | 281 | feeInfos.push(this.blockHistoryToFeeInfo(block, receipts)); 282 | } 283 | 284 | this.processFeeInfos(feeInfos, false); 285 | } 286 | 287 | private blockHistoryToFeeInfo(block: BlockWithTransactions, receipts: TransactionReceipt[]): FeeInfo { 288 | const transactionsByHash: Record = block.transactions.reduce( 289 | (v, tx) => ({ ...v, [tx.hash]: tx }), 290 | {}, 291 | ); 292 | 293 | const getGasPrice = (receipt: TransactionReceipt): number => { 294 | if (receipt.effectiveGasPrice) { 295 | return receipt.effectiveGasPrice.toNumber(); 296 | } 297 | 298 | const gasPrice = transactionsByHash[receipt.transactionHash].gasPrice; 299 | if (gasPrice) { 300 | return gasPrice.toNumber(); 301 | } 302 | 303 | return 0; 304 | }; 305 | 306 | const getPercentile = (values: number[], percentile: number): number => { 307 | const pos = ((values.length - 1) * percentile) / 100; 308 | const base = Math.floor(pos); 309 | const rest = pos - base; 310 | if (base + 1 < values.length) { 311 | return values[base] + rest * (values[base + 1] - values[base]); 312 | } else { 313 | return values[base]; 314 | } 315 | }; 316 | 317 | const effectiveGasPrices = receipts.map(getGasPrice).sort((a, b) => a - b); 318 | const percentiles = [1, 50, 99]; 319 | 320 | return { 321 | blockNumber: block.number, 322 | baseFee: block.baseFeePerGas?.toNumber(), 323 | gasUsedRatio: block.gasUsed.toNumber() / block.gasLimit.toNumber(), 324 | rewards: percentiles.map((percentile) => getPercentile(effectiveGasPrices, percentile)), 325 | }; 326 | } 327 | 328 | private processFeeInfos(feeInfos: FeeInfo[], reset: boolean) { 329 | if (reset) { 330 | this.feeInfos = []; 331 | } 332 | 333 | this.feeInfos.push(...feeInfos); 334 | this.feeInfos.sort((a, b) => b.blockNumber - a.blockNumber); 335 | this.tick(); 336 | } 337 | } 338 | 339 | type FeeHistoryResponseRaw = { 340 | baseFeePerGas: string[]; 341 | gasUsedRatio: number[]; 342 | oldestBlock: string; 343 | reward: string[][]; 344 | }; 345 | 346 | type FeeInfo = { 347 | blockNumber: number; 348 | baseFee: number | undefined; 349 | gasUsedRatio: number; 350 | rewards: number[]; 351 | }; 352 | -------------------------------------------------------------------------------- /components/helpers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BigNumber, BigNumberish, ethers } from 'ethers'; 3 | import { TraceMetadata } from './types'; 4 | import { formatUnits, ParamType } from 'ethers/lib/utils'; 5 | import { createTheme } from '@mui/material'; 6 | // noinspection ES6UnusedImports 7 | import {} from '@mui/lab/themeAugmentation'; 8 | import { TraceEntry, TraceEntryCall } from './api'; 9 | 10 | type TreeItemContentProps = { 11 | children: React.ReactNode; 12 | }; 13 | 14 | // lmao ethers wtf 15 | export const BuiltinErrors: Record< 16 | string, 17 | { signature: string; inputs: Array; name: string; reason?: boolean } 18 | > = { 19 | '0x08c379a0': { 20 | signature: 'Error(string)', 21 | name: 'Error', 22 | inputs: [ParamType.from('string message')], 23 | reason: true, 24 | }, 25 | '0x4e487b71': { signature: 'Panic(uint256)', name: 'Panic', inputs: [ParamType.from('uint256 code')] }, 26 | }; 27 | 28 | export const TreeItemContentSpan = (props: TreeItemContentProps) => { 29 | return ( 30 | 31 | { 34 | // we don't want the tree to focus onto the root element when a user is trying 35 | // to select text 36 | // 37 | // this has the side effect of preventing users from using arrow keys to navigate 38 | // see: https://github.com/mui/material-ui/issues/29518 39 | 40 | event.stopPropagation(); 41 | }} 42 | onClick={(event) => { 43 | // we don't want the tree item to expand when the user clicks on the context 44 | // 45 | // this has the side effect of disabling the ability to select the treeitem itself 46 | // but by now our tree is so fucked that it's fine 47 | event.stopPropagation(); 48 | }} 49 | style={{ 50 | whiteSpace: 'nowrap', 51 | fontFamily: 'monospace', 52 | letterSpacing: 'initial', 53 | }} 54 | > 55 | {props.children} 56 | 57 | 58 | ); 59 | }; 60 | 61 | export const toHash = (value: ethers.BigNumberish): string => { 62 | return '0x' + BigNumber.from(value).toHexString().substring(2).padStart(64, '0'); 63 | }; 64 | 65 | export const chunkString = (str: string, len: number): string[] => { 66 | const size = Math.ceil(str.length / len); 67 | const r = Array(size); 68 | let offset = 0; 69 | 70 | for (let i = 0; i < size; i++) { 71 | r[i] = str.substring(offset, offset + len); 72 | offset += len; 73 | } 74 | 75 | return r; 76 | }; 77 | 78 | export const findAffectedContract = (metadata: TraceMetadata, node: TraceEntry): [TraceEntryCall, TraceEntryCall[]] => { 79 | let path: TraceEntryCall[] = []; 80 | 81 | let parents = node.path.split('.'); 82 | 83 | while (parents.length > 0) { 84 | parents.pop(); 85 | 86 | let parentNode = metadata.nodesByPath[parents.join('.')]; 87 | if (parentNode.type === 'call') { 88 | path.push(parentNode); 89 | 90 | if (parentNode.variant !== 'delegatecall') { 91 | path.reverse(); 92 | 93 | return [parentNode, path]; 94 | } 95 | } 96 | } 97 | 98 | throw new Error("strange, didn't find parent node"); 99 | }; 100 | 101 | export const formatUnitsSmartly = (value: BigNumberish, nativeUnit?: string): string => { 102 | nativeUnit = (nativeUnit || 'eth').toUpperCase(); 103 | 104 | value = BigNumber.from(value); 105 | if (value.isZero()) { 106 | return `0 ${nativeUnit}`; 107 | } 108 | 109 | let chosenUnit; 110 | if (value.gte(BigNumber.from(100000000000000))) { 111 | chosenUnit = 'ether'; 112 | } else if (value.gte(BigNumber.from(100000))) { 113 | chosenUnit = 'gwei'; 114 | } else { 115 | chosenUnit = 'wei'; 116 | } 117 | 118 | let formattedValue = formatUnits(value, chosenUnit); 119 | 120 | if (chosenUnit === 'ether') { 121 | chosenUnit = nativeUnit; 122 | } 123 | 124 | return `${formattedValue} ${chosenUnit}`; 125 | }; 126 | 127 | export const formatUsd = (val: BigNumberish): string => { 128 | val = BigNumber.from(val); 129 | let formatted = formatUnits(val, 22); 130 | let [left, right] = formatted.split('.'); 131 | 132 | // we want at least 4 decimal places on the right 133 | right = right.substring(0, 4).padEnd(4, '0'); 134 | 135 | const isNegative = left.startsWith('-'); 136 | if (isNegative) { 137 | left = left.substring(1); 138 | } 139 | 140 | // we want comma delimited triplets on the left 141 | if (left.length > 3) { 142 | let parts = []; 143 | if (left.length % 3 !== 0) { 144 | parts.push(left.substring(0, left.length % 3)); 145 | left = left.substring(left.length % 3); 146 | } 147 | parts.push(chunkString(left, 3)); 148 | 149 | left = parts.join(','); 150 | } 151 | 152 | return `${isNegative ? '-' : ''}${left}.${right.substring(0, 4)} USD`; 153 | }; 154 | -------------------------------------------------------------------------------- /components/hooks/useFragment.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ConstructorFragment, ErrorFragment, EventFragment, Fragment, FunctionFragment } from '@ethersproject/abi'; 3 | 4 | const useFragmentInternal = ( 5 | initialFragment: S | null, 6 | defaultValue: string, 7 | fragmentParser: (v: string) => S | null, 8 | ): [string, React.Dispatch>, S | null] => { 9 | const [fragment, setFragment] = React.useState(initialFragment); 10 | 11 | const [fragmentString, setFragmentString] = React.useState( 12 | initialFragment ? initialFragment.format('full') : defaultValue, 13 | ); 14 | 15 | const setFragmentStringHook = (action: React.SetStateAction) => { 16 | setFragmentString((prevState) => { 17 | let newFragmentString; 18 | if (typeof action === 'string') { 19 | newFragmentString = action; 20 | } else { 21 | newFragmentString = action(prevState); 22 | } 23 | 24 | let newFragment; 25 | try { 26 | newFragment = fragmentParser(newFragmentString); 27 | } catch {} 28 | 29 | if (newFragment) { 30 | setFragment(newFragment); 31 | } 32 | 33 | return newFragmentString; 34 | }); 35 | }; 36 | 37 | return [fragmentString, setFragmentStringHook, fragment]; 38 | }; 39 | 40 | export const useFunctionFragment = ( 41 | initialFragment: FunctionFragment | null, 42 | defaultValue: string, 43 | ): [string, React.Dispatch>, FunctionFragment | null] => { 44 | return useFragmentInternal(initialFragment, defaultValue, (v) => { 45 | let newFragment = Fragment.from(v); 46 | return newFragment instanceof FunctionFragment ? newFragment : null; 47 | }); 48 | }; 49 | 50 | export const useErrorFragment = ( 51 | initialFragment: ErrorFragment | null, 52 | defaultValue: string, 53 | ): [string, React.Dispatch>, ErrorFragment | null] => { 54 | return useFragmentInternal(initialFragment, defaultValue, (v) => { 55 | let newFragment = Fragment.from(v); 56 | return newFragment instanceof ErrorFragment ? newFragment : null; 57 | }); 58 | }; 59 | 60 | export const useEventFragment = ( 61 | initialFragment: EventFragment | null, 62 | defaultValue: string, 63 | ): [string, React.Dispatch>, EventFragment | null] => { 64 | return useFragmentInternal(initialFragment, defaultValue, (v) => { 65 | let newFragment = Fragment.from(v); 66 | return newFragment instanceof EventFragment ? newFragment : null; 67 | }); 68 | }; 69 | 70 | export const useConstructorFragment = ( 71 | initialFragment: ConstructorFragment | null, 72 | defaultValue: string, 73 | ): [string, React.Dispatch>, ConstructorFragment | null] => { 74 | return useFragmentInternal(initialFragment, defaultValue, (v) => { 75 | let newFragment = Fragment.from(v); 76 | return newFragment instanceof ConstructorFragment ? newFragment : null; 77 | }); 78 | }; 79 | -------------------------------------------------------------------------------- /components/knownSlots.tsx: -------------------------------------------------------------------------------- 1 | type KnownSlot = { 2 | name: string; 3 | bits: number; 4 | type: string; 5 | }; 6 | 7 | export const knownSlots: Record = { 8 | '0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103': { 9 | name: 'proxyAdmin', 10 | type: 'address', 11 | bits: 160, 12 | }, 13 | '0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc': { 14 | name: 'proxyImplementation', 15 | type: 'address', 16 | bits: 160, 17 | }, 18 | '0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50': { 19 | name: 'proxyBeacon', 20 | type: 'address', 21 | bits: 160, 22 | }, 23 | '0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8': { 24 | name: 'guard', 25 | type: 'address', 26 | bits: 160, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /components/metadata/labels.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type LabelMetadata = { 4 | updater: React.Dispatch>; 5 | labels: Record; 6 | customLabels: Record>; 7 | }; 8 | 9 | export const defaultLabelMetadata = (): LabelMetadata => { 10 | return { 11 | updater: () => {}, 12 | labels: {}, 13 | customLabels: {}, 14 | }; 15 | }; 16 | 17 | export const LabelMetadataContext = React.createContext(defaultLabelMetadata()); 18 | -------------------------------------------------------------------------------- /components/metadata/preimages.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type PreimageMetadata = { 4 | updater: React.Dispatch>; 5 | preimages: Record; 6 | }; 7 | 8 | export const defaultPreimageMetadata = (): PreimageMetadata => { 9 | return { 10 | updater: () => {}, 11 | preimages: {}, 12 | }; 13 | }; 14 | 15 | export const PreimageMetadataContext = React.createContext(defaultPreimageMetadata()); 16 | -------------------------------------------------------------------------------- /components/metadata/prices.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ChainConfig } from '../Chains'; 3 | 4 | const NATIVE_TOKEN = 'native_token'; 5 | 6 | type CoinInfo = { 7 | confidence: number; 8 | decimals: number; 9 | price: number; 10 | symbol: string; 11 | timestamp: number; 12 | }; 13 | 14 | type DefiLlamaResponse = { 15 | coins: Record; 16 | }; 17 | 18 | export type PriceInfo = { 19 | decimals: number; 20 | currentPrice: bigint; 21 | historicalPrice: bigint; 22 | }; 23 | 24 | export type PriceMetadata = { 25 | updater: React.Dispatch>; 26 | status: Record; 27 | prices: Record; 28 | }; 29 | 30 | export const defaultPriceMetadata = (): PriceMetadata => { 31 | return { 32 | updater: () => {}, 33 | status: {}, 34 | prices: {}, 35 | }; 36 | }; 37 | 38 | export const PriceMetadataContext = React.createContext(defaultPriceMetadata()); 39 | 40 | export const toDefiLlamaId = (chainInfo: ChainConfig, token: string) => { 41 | if (token === chainInfo.nativeTokenAddress || token == NATIVE_TOKEN) { 42 | return chainInfo.coingeckoId; 43 | } 44 | 45 | return `${chainInfo.defillamaPrefix}:${token}`; 46 | }; 47 | 48 | export const getPriceOfToken = ( 49 | metadata: PriceMetadata, 50 | id: string, 51 | amount: bigint, 52 | type: 'current' | 'historical', 53 | ): bigint | null => { 54 | if (metadata.status[id] !== 'fetched') return null; 55 | 56 | const priceInfo = metadata.prices[id]; 57 | return ( 58 | amount * 59 | BigInt(10 ** (18 - priceInfo.decimals)) * 60 | (type === 'current' ? priceInfo.currentPrice : priceInfo.historicalPrice) 61 | ); 62 | }; 63 | 64 | export const fetchDefiLlamaPrices = ( 65 | setMetadata: React.Dispatch>, 66 | ids: string[], 67 | when: number, 68 | ): Promise => { 69 | return new Promise((resolve, reject) => { 70 | setTimeout(() => { 71 | setMetadata((prevState) => { 72 | const newState = { ...prevState }; 73 | 74 | const filteredIds = ids.filter((id) => newState.status[id] === undefined); 75 | 76 | if (filteredIds.length === 0) { 77 | resolve(); 78 | return prevState; 79 | } 80 | 81 | filteredIds.forEach((id) => (newState.status[id] = 'pending')); 82 | Promise.all([ 83 | fetch(`https://coins.llama.fi/prices/current/${filteredIds.join(',')}`) 84 | .then((resp) => resp.json()) 85 | .then((resp) => resp.coins), 86 | fetch(`https://coins.llama.fi/prices/historical/${when}/${filteredIds.join(',')}`) 87 | .then((resp) => resp.json()) 88 | .then((resp) => resp.coins), 89 | ]) 90 | .then(([current, historical]) => { 91 | resolve(); 92 | 93 | setMetadata((prevState) => { 94 | let newState = { ...prevState }; 95 | filteredIds.forEach((id) => { 96 | newState.status[id] = 'fetched'; 97 | newState.prices[id] = { 98 | decimals: 18, 99 | currentPrice: 0n, 100 | historicalPrice: 0n, 101 | }; 102 | 103 | if (current[id]) { 104 | if (current[id].decimals) { 105 | newState.prices[id].decimals = current[id].decimals; 106 | } 107 | newState.prices[id].currentPrice = BigInt((current[id].price * 10000) | 0); 108 | } 109 | if (historical[id]) { 110 | if (historical[id].decimals) { 111 | newState.prices[id].decimals = historical[id].decimals; 112 | } 113 | newState.prices[id].historicalPrice = BigInt((historical[id].price * 10000) | 0); 114 | } 115 | }); 116 | return newState; 117 | }); 118 | }) 119 | .catch(reject); 120 | 121 | return newState; 122 | }); 123 | }, 0); 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /components/metadata/search.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { defaultPriceMetadata } from './prices'; 3 | import { defaultContext } from './types'; 4 | 5 | export type SearchMetadata = { 6 | chain: string; 7 | txhash: string; 8 | }; 9 | 10 | export const defaultSearchMetadata = () => { 11 | return { 12 | chain: 'ethereum', 13 | txhash: '', 14 | }; 15 | }; 16 | 17 | export const SearchMetadataContext = React.createContext(defaultContext(defaultSearchMetadata())); 18 | -------------------------------------------------------------------------------- /components/metadata/tokens.ts: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder, ParamType } from '@ethersproject/abi'; 2 | import { BaseProvider } from '@ethersproject/providers'; 3 | import { ethers } from 'ethers'; 4 | import React from 'react'; 5 | import { SupportedChains } from '../Chains'; 6 | const NATIVE_TOKEN = 'native_token'; 7 | 8 | export type TokenInfo = { 9 | symbol?: string; 10 | decimals?: number; 11 | isNft?: boolean; 12 | }; 13 | 14 | export type TokenMetadata = { 15 | updater: React.Dispatch>; 16 | status: Record; 17 | tokens: Record; 18 | }; 19 | 20 | export const defaultTokenMetadata = (): TokenMetadata => { 21 | return { 22 | updater: () => {}, 23 | status: SupportedChains.reduce((o, chain) => { 24 | return { 25 | ...o, 26 | [chain.nativeTokenAddress]: 'fetched', 27 | }; 28 | }, {}), 29 | tokens: SupportedChains.reduce((o, chain) => { 30 | return { 31 | ...o, 32 | [chain.nativeTokenAddress]: { 33 | symbol: chain.nativeSymbol, 34 | decimals: 18, 35 | isNft: false, 36 | }, 37 | }; 38 | }, {}), 39 | }; 40 | }; 41 | 42 | export const TokenMetadataContext = React.createContext(defaultTokenMetadata()); 43 | 44 | export const fetchTokenMetadata = ( 45 | setMetadata: React.Dispatch>, 46 | provider: BaseProvider, 47 | tokens: Array, 48 | ) => { 49 | return new Promise((resolve, reject) => { 50 | setTimeout(() => { 51 | setMetadata((prevState) => { 52 | const filteredTokens = tokens.filter( 53 | (token) => prevState.status[token] === undefined && token != NATIVE_TOKEN, 54 | ); 55 | 56 | if (filteredTokens.length === 0) { 57 | resolve(); 58 | return prevState; 59 | } 60 | 61 | const newState = { ...prevState }; 62 | filteredTokens.forEach((token) => (newState.status[token] = 'pending')); 63 | 64 | Promise.all( 65 | filteredTokens 66 | .map((token) => { 67 | return [ 68 | provider 69 | .call({ 70 | to: token, 71 | data: ethers.utils.id('decimals()').substring(0, 10), 72 | }) 73 | .then((decimalsHex) => { 74 | const decimals = BigInt(decimalsHex); 75 | 76 | if (decimals > 255n) { 77 | throw new Error( 78 | `tried to fetch decimals for token ${token} but got illegal value ${decimalsHex}`, 79 | ); 80 | } 81 | 82 | return { 83 | token: token, 84 | type: 'decimals', 85 | decimals: Number(decimals), 86 | } as { token: string; type: 'decimals'; decimals: number }; 87 | }) 88 | .catch(console.error), 89 | provider 90 | .call({ 91 | to: token, 92 | data: ethers.utils.id('symbol()').substring(0, 10), 93 | }) 94 | .then((symbolHex) => { 95 | let symbol; 96 | 97 | if (symbolHex.length === 66) { 98 | symbol = ethers.utils.toUtf8String(symbolHex.replace(/(00)+$/g, '')); 99 | } else { 100 | try { 101 | let results = defaultAbiCoder.decode( 102 | [ParamType.from('string')], 103 | symbolHex, 104 | ); 105 | symbol = results[0].toString(); 106 | } catch (e) { 107 | throw new Error( 108 | `tried to fetch symbol for token ${token} but got illegal value ${symbolHex}`, 109 | ); 110 | } 111 | } 112 | 113 | return { 114 | token: token, 115 | type: 'symbol', 116 | symbol: symbol, 117 | } as { token: string; type: 'symbol'; symbol: string }; 118 | }) 119 | .catch(console.error), 120 | provider 121 | .call({ 122 | to: token, 123 | data: 124 | ethers.utils.id('supportsInterface(bytes4)').substring(0, 10) + 125 | defaultAbiCoder.encode(['bytes4'], ['0x80ac58cd']).substring(2), 126 | }) 127 | .then((isNftHex) => { 128 | const isNft = isNftHex.length > 2 ? BigInt(isNftHex) == 1n : false; 129 | 130 | return { 131 | token: token, 132 | type: 'isNft', 133 | isNft: isNft, 134 | } as { token: string; type: 'isNft'; isNft: boolean }; 135 | }) 136 | .catch(console.error), 137 | ]; 138 | }) 139 | .flatMap((x) => x), 140 | ) 141 | .then((results) => { 142 | resolve(); 143 | 144 | setMetadata((prevState) => { 145 | const newState = { ...prevState }; 146 | filteredTokens.forEach((token) => { 147 | newState.status[token] = 'fetched'; 148 | newState.tokens[token] = {}; 149 | }); 150 | 151 | results.forEach((result) => { 152 | if (!result) return; 153 | 154 | if (result.type === 'decimals') { 155 | newState.tokens[result.token].decimals = result.decimals; 156 | } else if (result.type === 'symbol') { 157 | newState.tokens[result.token].symbol = result.symbol; 158 | } else if (result.type === 'isNft') { 159 | newState.tokens[result.token].isNft = result.isNft; 160 | } 161 | }); 162 | 163 | return newState; 164 | }); 165 | }) 166 | .catch(reject); 167 | 168 | return newState; 169 | }); 170 | }); 171 | }); 172 | }; 173 | -------------------------------------------------------------------------------- /components/metadata/transaction.ts: -------------------------------------------------------------------------------- 1 | import { TransactionReceipt, TransactionResponse } from '@ethersproject/abstract-provider'; 2 | import React from 'react'; 3 | 4 | export type MinedTransaction = { 5 | receipt: TransactionReceipt; 6 | timestamp: number; 7 | }; 8 | 9 | export type TransactionMetadata = { 10 | transaction: TransactionResponse; 11 | result: MinedTransaction | null; 12 | }; 13 | 14 | export const TransactionMetadataContext = React.createContext({} as TransactionMetadata); 15 | -------------------------------------------------------------------------------- /components/metadata/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type MetadataContext = T & { setMetadata: React.Dispatch> }; 4 | 5 | export const defaultContext = (defaultValue: T): MetadataContext => { 6 | return { 7 | ...defaultValue, 8 | setMetadata: () => {}, 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /components/precompiles.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionFragment } from '@ethersproject/abi/lib'; 2 | import { BigNumber, ethers } from 'ethers'; 3 | 4 | type Precompile = { 5 | name: string; 6 | fragment: FunctionFragment; 7 | parseInput: (data: string) => any[]; 8 | parseOutput: (data: string) => any[]; 9 | }; 10 | 11 | export const precompiles: Record = { 12 | '0x0000000000000000000000000000000000000001': { 13 | name: 'ecrecover', 14 | fragment: FunctionFragment.from( 15 | `ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address signer)`, 16 | ), 17 | parseInput: (data: string) => { 18 | data = data.substring(2); 19 | if (data.length < 64 * 4) return []; 20 | 21 | return [ 22 | '0x' + data.substring(0, 64), 23 | BigNumber.from('0x' + data.substring(64, 64 * 2)), 24 | '0x' + data.substring(64 * 2, 64 * 3), 25 | '0x' + data.substring(64 * 3, 64 * 4), 26 | ]; 27 | }, 28 | parseOutput: (data: string) => { 29 | data = data.substring(2); 30 | if (data.length < 12 * 2) return []; 31 | return [ethers.utils.getAddress(data.substring(12 * 2))]; 32 | }, 33 | }, 34 | '0x0000000000000000000000000000000000000002': { 35 | name: 'sha256', 36 | fragment: FunctionFragment.from(`sha256(bytes memory data) returns (bytes32 hash)`), 37 | parseInput: (data: string) => [data], 38 | parseOutput: (data: string) => [data], 39 | }, 40 | '0x0000000000000000000000000000000000000003': { 41 | name: 'ripemd160', 42 | fragment: FunctionFragment.from(`ripemd160(bytes memory data) returns (bytes20 hash)`), 43 | parseInput: (data: string) => [data], 44 | parseOutput: (data: string) => ['0x' + data.substring(2 + 12 * 2).padEnd(64, '0')], 45 | }, 46 | '0x0000000000000000000000000000000000000004': { 47 | name: 'identity', 48 | fragment: FunctionFragment.from(`identity(bytes memory data) returns (bytes memory data)`), 49 | parseInput: (data: string) => [data], 50 | parseOutput: (data: string) => [data], 51 | }, 52 | '0x0000000000000000000000000000000000000005': { 53 | name: 'modexp', 54 | fragment: FunctionFragment.from( 55 | `modexp(uint baseLen, uint expLen, uint modLen, bytes memory base, bytes memory exp, bytes memory mod) returns (bytes memory data)`, 56 | ), 57 | parseInput: (data: string) => { 58 | data = data.substring(2); 59 | 60 | let baseLen = BigNumber.from('0x' + data.substring(0, 64)).toNumber(); 61 | let expLen = BigNumber.from('0x' + data.substring(64, 64 * 2)).toNumber(); 62 | let modLen = BigNumber.from('0x' + data.substring(64 * 2, 64 * 3)).toNumber(); 63 | let base = '0x' + data.substring(0, baseLen * 2); 64 | let exp = '0x' + data.substring(baseLen * 2, baseLen * 2 + expLen * 2); 65 | let mod = '0x' + data.substring(baseLen * 2 + expLen * 2, baseLen * 2 + expLen * 2 + modLen * 2); 66 | return [baseLen, expLen, modLen, base, exp, mod]; 67 | }, 68 | parseOutput: (data: string) => [data], 69 | }, 70 | '0x0000000000000000000000000000000000000006': { 71 | name: 'ecadd', 72 | fragment: FunctionFragment.from( 73 | `ecadd(bytes32 x1, bytes32 y1, bytes32 x2, bytes32 y2) returns (bytes32 x, bytes32 y)`, 74 | ), 75 | parseInput: (data: string) => { 76 | data = data.substring(2); 77 | 78 | return [ 79 | '0x' + data.substring(0, 64), 80 | '0x' + data.substring(64, 64 * 2), 81 | '0x' + data.substring(64 * 2, 64 * 3), 82 | '0x' + data.substring(64 * 3, 64 * 4), 83 | ]; 84 | }, 85 | parseOutput: (data: string) => { 86 | data = data.substring(2); 87 | return ['0x' + data.substring(0, 64), '0x' + data.substring(64, 64 * 2)]; 88 | }, 89 | }, 90 | '0x0000000000000000000000000000000000000007': { 91 | name: 'ecmul', 92 | fragment: FunctionFragment.from(`ecadd(bytes32 x1, bytes32 y1, bytes32 s) returns (bytes32 x, bytes32 y)`), 93 | parseInput: (data: string) => { 94 | data = data.substring(2); 95 | 96 | return [ 97 | '0x' + data.substring(0, 64), 98 | '0x' + data.substring(64, 64 * 2), 99 | '0x' + data.substring(64 * 2, 64 * 3), 100 | ]; 101 | }, 102 | parseOutput: (data: string) => { 103 | data = data.substring(2); 104 | return ['0x' + data.substring(0, 64), '0x' + data.substring(64, 64 * 2)]; 105 | }, 106 | }, 107 | '0x0000000000000000000000000000000000000008': { 108 | name: 'ecmul', 109 | fragment: FunctionFragment.from( 110 | `ecpairing(tuple(tuple(bytes32 x, bytes32 y) curvePoint, tuple(tuple(bytes32 x, bytes32 y) x, tuple(bytes32 x, bytes32 y) y) twistPoint)[] memory inputs) returns (bool success)`, 111 | ), 112 | parseInput: (data: string) => { 113 | data = data.substring(2); 114 | 115 | let inputs = []; 116 | while (data.length > 0) { 117 | inputs.push([ 118 | ['0x' + data.substring(0, 64), '0x' + data.substring(64, 64 * 2)], 119 | [ 120 | ['0x' + data.substring(64 * 2, 64 * 3), '0x' + data.substring(64 * 3, 64 * 4)], 121 | ['0x' + data.substring(64 * 4, 64 * 5), '0x' + data.substring(64 * 5, 64 * 6)], 122 | ], 123 | ]); 124 | 125 | data = data.substring(384); 126 | } 127 | 128 | return [inputs]; 129 | }, 130 | parseOutput: (data: string) => { 131 | data = data.substring(2); 132 | return [!BigNumber.from('0x' + data).isZero()]; 133 | }, 134 | }, 135 | '0x0000000000000000000000000000000000000009': { 136 | name: 'blake2f', 137 | fragment: FunctionFragment.from( 138 | `blake2f(uint32 rounds, bytes8[8] memory h, bytes8[16] m, bytes8 t1, bytes8 t2, bool f) returns (bytes8[8] h)`, 139 | ), 140 | parseInput: (data: string) => { 141 | data = data.substring(2); 142 | 143 | let rounds = BigNumber.from(data.substring(0, 8)); 144 | let h = []; 145 | for (let i = 0; i < 8; i++) { 146 | h.push(data.substring(8 + i * 16, 8 + (i + 1) * 16)); 147 | } 148 | let m = []; 149 | for (let i = 0; i < 16; i++) { 150 | m.push(data.substring(68 + i * 16, 68 + (i + 1) * 16)); 151 | } 152 | let t1 = data.substring(392, 392 + 16); 153 | let t2 = data.substring(392 + 16, 392 + 16 * 2); 154 | let f = !BigNumber.from('0x' + data.substring(424)).isZero(); 155 | 156 | return [rounds, h, m, t1, t2, f]; 157 | }, 158 | parseOutput: (data: string) => { 159 | data = data.substring(2); 160 | let h = []; 161 | for (let i = 0; i < 8; i++) { 162 | h.push(data.substring(8 + i * 16, 8 + (i + 1) * 16)); 163 | } 164 | return [h]; 165 | }, 166 | }, 167 | }; 168 | -------------------------------------------------------------------------------- /components/trace/CallTraceTreeItem.tsx: -------------------------------------------------------------------------------- 1 | import { defaultAbiCoder, ParamType, Result } from '@ethersproject/abi'; 2 | import VisibilityIcon from '@mui/icons-material/Visibility'; 3 | import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; 4 | import { Grid } from '@mui/material'; 5 | import { BigNumber, ethers } from 'ethers'; 6 | import { formatEther } from 'ethers/lib/utils'; 7 | import * as React from 'react'; 8 | import { useContext } from 'react'; 9 | import { TraceEntryCall, TraceEntryLog, TraceEntrySload, TraceEntrySstore, TraceResponse } from '../api'; 10 | import { guessFragment } from '@samczsun/abi-guesser/dist/encode-guesser'; 11 | import { ChainConfigContext } from '../Chains'; 12 | import { DataRenderer } from '../DataRenderer'; 13 | import { EncodedABITextField } from '../EncodedABITextField'; 14 | import { FragmentTextField } from '../FragmentTextField'; 15 | import { BuiltinErrors, findAffectedContract } from '../helpers'; 16 | import { useErrorFragment, useFunctionFragment } from '../hooks/useFragment'; 17 | import { ParamFlatView } from '../ParamFlatView'; 18 | import { ParamTreeView } from '../ParamTreeView'; 19 | import { precompiles } from '../precompiles'; 20 | import { SpanIconButton } from '../SpanIconButton'; 21 | import { StorageMetadata, TraceMetadata } from '../types'; 22 | import { LogTraceTreeItem } from './LogTraceTreeItem'; 23 | import { TraceTreeDialog } from './TraceTreeDialog'; 24 | import { TraceTreeItem, TraceTreeNodeLabel } from './TraceTreeItem'; 25 | 26 | const callColor = { 27 | call: '#2c2421', 28 | staticcall: '#00ad9c', 29 | callcode: '#df5320', 30 | delegatecall: '#f22c40', 31 | }; 32 | 33 | type CallTraceTreeItemProps = { 34 | traceResult: TraceResponse; 35 | traceMetadata: TraceMetadata; 36 | storageMetadata: StorageMetadata; 37 | requestStorageMetadata: (chain: string, affectedCall: TraceEntryCall, actualCall: TraceEntryCall) => void; 38 | showStorageChanges: boolean; 39 | setShowStorageChanges: (show: boolean) => void; 40 | expandTo: (id: string) => void; 41 | 42 | node: TraceEntryCall; 43 | 44 | children?: JSX.Element[]; 45 | }; 46 | 47 | export const CallTraceTreeItem = (props: CallTraceTreeItemProps) => { 48 | const { traceResult, traceMetadata, node, showStorageChanges, setShowStorageChanges, children } = props; 49 | 50 | const chainConfig = useContext(ChainConfigContext); 51 | 52 | const [functionFragment, setFunctionFragment, parsedFunctionFragment] = useFunctionFragment( 53 | (() => { 54 | if (node.input.length > 2) { 55 | try { 56 | return traceMetadata.abis[node.to][node.codehash].getFunction( 57 | node.input.substring(0, 10).toLowerCase(), 58 | ); 59 | } catch (e) {} 60 | } 61 | 62 | try { 63 | return guessFragment(node.input); 64 | } catch (e) { 65 | console.log('failed to guess fragment', e); 66 | return null; 67 | } 68 | })(), 69 | `function func_${node.input.substring(2, 10).padEnd(8, '0')}()`, 70 | ); 71 | 72 | const [errorFragment, setErrorFragment, parsedErrorFragment] = useErrorFragment( 73 | (() => { 74 | if (node.status === 0 && node.output.length > 2) { 75 | try { 76 | return traceMetadata.abis[node.to][node.codehash].getError( 77 | node.output.substring(0, 10).toLowerCase(), 78 | ); 79 | } catch (e) {} 80 | } 81 | 82 | return null; 83 | })(), 84 | BuiltinErrors[node.output.substring(0, 10).toLowerCase()] 85 | ? 'error ' + BuiltinErrors[node.output.substring(0, 10).toLowerCase()].signature 86 | : `error Error()`, 87 | ); 88 | 89 | const [nodeInput, setNodeInput] = React.useState(node.input); 90 | const [nodeOutput, setNodeOutput] = React.useState(node.output); 91 | const [open, setOpen] = React.useState(false); 92 | 93 | let dialogTitle: JSX.Element | null; 94 | let dialogContent: JSX.Element | null; 95 | 96 | let functionName: string; 97 | let fragmentInputs: ParamType[] | undefined = undefined; 98 | let fragmentOutputs: ParamType[] | undefined = undefined; 99 | let functionParams = <>{node.input}; 100 | let functionReturns = <>{node.output}; 101 | let parsedInput: Result | null = null; 102 | let parsedOutput: Result | null = null; 103 | 104 | if (node.isPrecompile) { 105 | if (node.to in precompiles) { 106 | let precompile = precompiles[node.to]; 107 | functionName = precompile.name; 108 | fragmentInputs = precompile.fragment.inputs; 109 | fragmentOutputs = precompile.fragment.outputs; 110 | parsedInput = precompile.parseInput(node.input); 111 | parsedOutput = precompile.parseOutput(node.output); 112 | } else { 113 | functionName = 'call'; 114 | } 115 | } else { 116 | if (parsedFunctionFragment) { 117 | functionName = parsedFunctionFragment.name; 118 | 119 | fragmentInputs = parsedFunctionFragment.inputs; 120 | functionParams = <>0x{node.input.substring(10)}; 121 | try { 122 | parsedInput = defaultAbiCoder.decode(fragmentInputs, ethers.utils.arrayify(node.input).slice(4)); 123 | parsedInput.forEach((v) => v.toString()); 124 | } catch (err) { 125 | parsedInput = null; 126 | } 127 | 128 | if (node.status === 1) { 129 | fragmentOutputs = parsedFunctionFragment.outputs; 130 | if (fragmentOutputs) { 131 | try { 132 | parsedOutput = defaultAbiCoder.decode(fragmentOutputs, ethers.utils.arrayify(node.output)); 133 | parsedOutput.forEach((v) => v.toString()); 134 | } catch (err) { 135 | parsedOutput = null; 136 | } 137 | } 138 | } 139 | } else if (node.input.length == 2) { 140 | functionName = 'fallback'; 141 | functionParams = <>; 142 | } else { 143 | functionName = `call`; 144 | } 145 | 146 | if (node.status === 0) { 147 | if (parsedErrorFragment) { 148 | fragmentOutputs = parsedErrorFragment.inputs; 149 | try { 150 | parsedOutput = defaultAbiCoder.decode(fragmentOutputs, ethers.utils.arrayify(node.output).slice(4)); 151 | parsedOutput.forEach((v) => v.toString()); 152 | } catch (err) { 153 | parsedOutput = null; 154 | } 155 | } else { 156 | if (node.output.slice(0, 10) in BuiltinErrors) { 157 | fragmentOutputs = BuiltinErrors[node.output.slice(0, 10)].inputs; 158 | try { 159 | parsedOutput = defaultAbiCoder.decode( 160 | fragmentOutputs, 161 | ethers.utils.arrayify(node.output).slice(4), 162 | ); 163 | parsedOutput.forEach((v) => v.toString()); 164 | } catch (err) { 165 | parsedOutput = null; 166 | } 167 | } else { 168 | fragmentOutputs = [ParamType.from('string message')]; 169 | try { 170 | parsedOutput = defaultAbiCoder.decode(fragmentOutputs, ethers.utils.arrayify(node.output)); 171 | parsedOutput.forEach((v) => v.toString()); 172 | } catch (err) { 173 | parsedOutput = null; 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | let inputParamFlatView; 181 | let inputParamTreeView; 182 | let outputParamFlatView; 183 | let outputParamTreeView; 184 | if (fragmentInputs && parsedInput) { 185 | inputParamFlatView = ( 186 | 187 | ); 188 | inputParamTreeView = ( 189 | 195 | ); 196 | } else { 197 | inputParamFlatView = functionParams; 198 | } 199 | if (fragmentOutputs && parsedOutput) { 200 | outputParamFlatView = ( 201 | 202 | ); 203 | outputParamTreeView = ( 204 | 210 | ); 211 | } else { 212 | outputParamFlatView = functionReturns; 213 | } 214 | 215 | dialogTitle = ( 216 | <> 217 | . 218 | {functionName} 219 | 220 | ); 221 | dialogContent = ( 222 | <> 223 | 224 | 225 | Trace Path: {node.path} 226 | 227 | 228 | Type: {node.variant} 229 | 230 | 231 | Gas Used: {node.gasUsed} 232 | 233 | 234 | 241 | 242 | 243 | 250 | 251 | 252 | 253 | 254 | {node.status === 0 ? ( 255 | 256 | 257 | 258 | ) : null} 259 | Decoded Inputs: 260 | 261 | {inputParamTreeView} 262 | 263 | Decoded {node.status === 0 ? 'Errors:' : 'Outputs:'} 264 | 265 | {outputParamTreeView} 266 | 267 | Subcall Logs: 268 | 269 | {Object.values(traceMetadata.nodesByPath) 270 | .filter((v): v is TraceEntryLog => v.type === 'log') 271 | .filter((v) => v.path.startsWith(node.path + '.')) 272 | .sort((a, b) => a.path.localeCompare(b.path)) 273 | .map((node) => { 274 | return ( 275 | { 278 | props.expandTo(node.path); 279 | }} 280 | showAddress={true} 281 | traceResult={traceResult} 282 | traceMetadata={traceMetadata} 283 | node={node} 284 | /> 285 | ); 286 | })} 287 | 288 | 289 | 290 | ); 291 | 292 | let storageToggle = null; 293 | 294 | let storageNode = node.children.find( 295 | (v): v is TraceEntrySstore | TraceEntrySload => v.type === 'sload' || v.type === 'sstore', 296 | ); 297 | if (storageNode) { 298 | storageToggle = ( 299 | { 302 | if (!showStorageChanges && storageNode) { 303 | let [storageParent, path] = findAffectedContract(traceMetadata, storageNode); 304 | path.forEach((node) => { 305 | props.requestStorageMetadata(traceResult.chain, storageParent, node); 306 | }); 307 | } 308 | 309 | setShowStorageChanges(!showStorageChanges); 310 | }} 311 | /> 312 | ); 313 | } 314 | 315 | let address; 316 | let addressContent = ; 317 | if (node.status === 0) { 318 | address = {addressContent}; 319 | } else { 320 | address = addressContent; 321 | } 322 | let valueNode; 323 | 324 | let value = BigNumber.from(node.value); 325 | if (value.gt(0)) { 326 | valueNode = {`[${formatEther(value)} ${chainConfig.nativeSymbol}]`}; 327 | } 328 | 329 | let treeContent = ( 330 | <> 331 | setOpen(true)} 335 | /> 336 | {`[${node.gasUsed}]`} 337 | {storageToggle} 338 |   339 | {address}.{functionName} 340 | {valueNode}({inputParamFlatView}) → ({outputParamFlatView}) 341 | 342 | ); 343 | 344 | return ( 345 | <> 346 | 347 | 348 | {children} 349 | 350 | 351 | ); 352 | }; 353 | -------------------------------------------------------------------------------- /components/trace/LogTraceTreeItem.tsx: -------------------------------------------------------------------------------- 1 | import { TraceEntryCallable, TraceMetadata } from '../types'; 2 | import * as React from 'react'; 3 | import { ParamType } from '@ethersproject/abi'; 4 | import { ParamFlatView } from '../ParamFlatView'; 5 | import { DataRenderer } from '../DataRenderer'; 6 | import { Grid, List, ListItem } from '@mui/material'; 7 | import { ParamTreeView } from '../ParamTreeView'; 8 | import { TraceTreeItem, TraceTreeNodeLabel } from './TraceTreeItem'; 9 | import { TraceTreeDialog } from './TraceTreeDialog'; 10 | import { EncodedABITextField } from '../EncodedABITextField'; 11 | import { FragmentTextField } from '../FragmentTextField'; 12 | import { useEventFragment } from '../hooks/useFragment'; 13 | import { TraceEntryLog, TraceResponse } from '../api'; 14 | 15 | type LogTraceTreeItemProps = { 16 | traceResult: TraceResponse; 17 | traceMetadata: TraceMetadata; 18 | node: TraceEntryLog; 19 | onClick?: () => void; 20 | showAddress?: boolean; 21 | 22 | children?: JSX.Element[]; 23 | }; 24 | 25 | export const LogTraceTreeItem = (props: LogTraceTreeItemProps) => { 26 | const { traceResult, traceMetadata, node, children } = props; 27 | 28 | const [open, setOpen] = React.useState(false); 29 | 30 | let parentId = node.path.split('.'); 31 | parentId.pop(); 32 | let parentNode = traceMetadata.nodesByPath[parentId.join('.')] as TraceEntryCallable; 33 | 34 | const [eventFragment, setEventFragment, parsedEventFragment] = useEventFragment( 35 | (() => { 36 | if (node.topics.length > 0) { 37 | try { 38 | return traceMetadata.abis[parentNode.to][parentNode.codehash].getEvent(node.topics[0]); 39 | } catch (e) {} 40 | } else { 41 | return ( 42 | Object.values(traceMetadata.abis[parentNode.to][parentNode.codehash].events).find( 43 | (event) => event.anonymous, 44 | ) || null 45 | ); 46 | } 47 | 48 | return null; 49 | })(), 50 | `event Event()`, 51 | ); 52 | 53 | const [nodeData, setNodeData] = React.useState(node.data); 54 | 55 | let dialogTitle: JSX.Element; 56 | let dialogContent: JSX.Element; 57 | 58 | let fakeParams = [ 59 | ...node.topics.map((v, i) => { 60 | return ParamType.from(`bytes32 topic_${i}`); 61 | }), 62 | ParamType.from(`bytes data`), 63 | ]; 64 | let fakeValues = [...node.topics, node.data]; 65 | 66 | let eventName; 67 | let eventParams = ; 68 | if (node.topics.length > 0) { 69 | eventName = <>{node.topics[0]}; 70 | } else { 71 | eventName = <>Anonymous Event; 72 | } 73 | 74 | let parsedEvent; 75 | if (parsedEventFragment) { 76 | eventName = {parsedEventFragment.name}; 77 | 78 | try { 79 | let abi = traceMetadata.abis[parentNode.to][parentNode.codehash]; 80 | let mangledTopics; 81 | if (!parsedEventFragment.anonymous) { 82 | mangledTopics = [abi.getEventTopic(parsedEventFragment), ...node.topics.slice(1)]; 83 | } else { 84 | mangledTopics = node.topics; 85 | } 86 | parsedEvent = abi.decodeEventLog(parsedEventFragment, node.data, mangledTopics); 87 | parsedEvent.forEach((v) => v.toString()); 88 | if (parsedEvent) { 89 | eventParams = ( 90 | 95 | ); 96 | } 97 | } catch (e) { 98 | parsedEvent = null; 99 | } 100 | } 101 | 102 | dialogTitle = ( 103 | <> 104 | {}. 105 | {eventName} 106 | 107 | ); 108 | dialogContent = ( 109 | <> 110 | 111 | 112 | Trace Path: {node.path} 113 | 114 | 115 | Event Topics:
116 | 117 | {node.topics.map((v, i) => ( 118 | 119 | {v} 120 | 121 | ))} 122 | 123 |
124 | 125 | 132 | 133 | 134 | 135 | 136 | Decoded Data: 137 | 138 | {parsedEventFragment && parsedEvent ? ( 139 | 145 | ) : ( 146 | 152 | )} 153 | 154 |
155 | 156 | ); 157 | 158 | let treeContent = ( 159 | <> 160 | setOpen(true))} 164 | /> 165 |   166 | {props.showAddress ? ( 167 | <> 168 | . 169 | 170 | ) : null} 171 | {eventName}({eventParams}) 172 | 173 | ); 174 | 175 | return ( 176 | <> 177 | 178 | 179 | {children} 180 | 181 | 182 | ); 183 | }; 184 | -------------------------------------------------------------------------------- /components/trace/SloadTraceTreeItem.tsx: -------------------------------------------------------------------------------- 1 | import { StorageMetadata, TraceMetadata } from '../types'; 2 | import * as React from 'react'; 3 | import TreeView from '@mui/lab/TreeView'; 4 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 5 | import ChevronRightIcon from '@mui/icons-material/ChevronRight'; 6 | import { renderSlotTree } from './SlotTree'; 7 | import { DataRenderer } from '../DataRenderer'; 8 | import TreeItem from '@mui/lab/TreeItem'; 9 | import { findAffectedContract, TreeItemContentSpan } from '../helpers'; 10 | import WithSeparator from 'react-with-separator'; 11 | import { Grid } from '@mui/material'; 12 | import { TraceTreeItem, TraceTreeNodeLabel } from './TraceTreeItem'; 13 | import { TraceTreeDialog } from './TraceTreeDialog'; 14 | import { TraceEntrySload, TraceResponse } from '../api'; 15 | 16 | type SloadTraceTreeItemProps = { 17 | traceResult: TraceResponse; 18 | traceMetadata: TraceMetadata; 19 | storageMetadata: StorageMetadata; 20 | node: TraceEntrySload; 21 | 22 | children?: JSX.Element[]; 23 | }; 24 | 25 | export const SloadTraceTreeItem = (props: SloadTraceTreeItemProps) => { 26 | const { traceResult, traceMetadata, storageMetadata, node, children } = props; 27 | 28 | const [open, setOpen] = React.useState(false); 29 | 30 | let [affectedCall] = findAffectedContract(traceMetadata, node); 31 | 32 | let ourSlots = storageMetadata.slots[affectedCall.to][affectedCall.codehash]; 33 | 34 | let variablesSorted = Object.entries(ourSlots[node.slot].variables) 35 | .sort((a, b) => parseInt(a[0]) - parseInt(b[0])) 36 | .map(([offset, variableInfo]) => { 37 | let start = parseInt(offset); 38 | let end = start + variableInfo.bits; 39 | return { 40 | name: variableInfo.fullName, 41 | start: start, 42 | end: end, 43 | type: variableInfo.typeName.typeDescriptions.typeString, 44 | }; 45 | }); 46 | 47 | if (!variablesSorted.length) { 48 | variablesSorted = [ 49 | { 50 | name: node.slot, 51 | start: 0, 52 | end: 256, 53 | type: 'bytes32', 54 | }, 55 | ]; 56 | } 57 | 58 | let vars = variablesSorted.map((v, i) => { 59 | let start = node.value.length - v.end / 4; 60 | let end = node.value.length - v.start / 4; 61 | return { 62 | name: v.name, 63 | value: node.value.substring(start, end), 64 | type: v.type, 65 | }; 66 | }); 67 | 68 | let dialogSlotTree = ( 69 | } 72 | defaultExpandIcon={} 73 | > 74 | {renderSlotTree(ourSlots, node.slot, 'root')} 75 | 76 | ); 77 | 78 | let dialogValues = ( 79 | } 82 | defaultExpandIcon={} 83 | > 84 | {vars.map((v, i) => { 85 | let dataRenderer = ; 86 | 87 | return ( 88 | 93 | {v.name}: {dataRenderer} 94 | 95 | } 96 | /> 97 | ); 98 | })} 99 | 100 | ); 101 | 102 | let dialogTitle = ( 103 | <> 104 | sload  105 | , }> 106 | {variablesSorted.map((v) => { 107 | return {v.name}; 108 | })} 109 | 110 | 111 | ); 112 | 113 | let dialogContent = ( 114 | <> 115 | 116 | 117 | Trace Path: {node.path} 118 | 119 | 120 | Slot: {node.slot} 121 | 122 | 123 | Value: {node.value} 124 | 125 | Decoded slot trace: {dialogSlotTree} 126 | Decoded values: {dialogValues} 127 | 128 | 129 | ); 130 | 131 | let treeContent = ( 132 | <> 133 | setOpen(true)} /> 134 |   135 | , }> 136 | {vars.map((v, i) => { 137 | return ( 138 | 139 | {v.name}:  140 | 141 | 142 | ); 143 | })} 144 | 145 | 146 | ); 147 | 148 | return ( 149 | <> 150 | 151 | 152 | {children} 153 | 154 | 155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /components/trace/SlotTree.tsx: -------------------------------------------------------------------------------- 1 | import { SlotInfo } from '../types'; 2 | import WithSeparator from 'react-with-separator'; 3 | import * as React from 'react'; 4 | import TreeItem from '@mui/lab/TreeItem'; 5 | import { TreeItemContentSpan } from '../helpers'; 6 | 7 | type SlotTreeItemContentProps = { 8 | slot: string; 9 | slotInfo: SlotInfo; 10 | }; 11 | 12 | const SlotTreeItemContent = (props: SlotTreeItemContentProps) => { 13 | const { slot, slotInfo } = props; 14 | 15 | let content; 16 | 17 | if (slotInfo.resolved) { 18 | content = ( 19 | <> 20 | {`name=`} 21 | , }> 22 | {Object.values(slotInfo.variables).map((v) => v.fullName)} 23 | 24 |  {`slot=${slot}`} 25 | 26 | ); 27 | } else { 28 | content = ( 29 | <> 30 | {`slot=${slot}` + 31 | (slotInfo.type === 'mapping' || slotInfo.type === 'array' ? ` offset=${slotInfo.offset}` : '') + 32 | ` type=${slotInfo.type}`} 33 | 34 | ); 35 | } 36 | 37 | return content; 38 | }; 39 | 40 | export const renderSlotTree = (resolvedStorageSlots: Record, slot: string, path: string) => { 41 | let children = []; 42 | let slotInfo = resolvedStorageSlots[slot]; 43 | if (slotInfo.type === 'mapping') { 44 | children.push(renderSlotTree(resolvedStorageSlots, slotInfo.baseSlot, path + '.slot')); 45 | children.push( 46 | key={slotInfo.mappingKey}} 50 | />, 51 | ); 52 | } else if (slotInfo.type === 'array') { 53 | children.push(renderSlotTree(resolvedStorageSlots, slotInfo.baseSlot, path + '.slot')); 54 | } 55 | 56 | return ( 57 | 66 | 67 | 68 | } 69 | > 70 | {children} 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /components/trace/SstoreTraceTreeItem.tsx: -------------------------------------------------------------------------------- 1 | import { StorageMetadata, TraceMetadata } from '../types'; 2 | import * as React from 'react'; 3 | import TreeView from '@mui/lab/TreeView'; 4 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 5 | import ChevronRightIcon from '@mui/icons-material/ChevronRight'; 6 | import { renderSlotTree } from './SlotTree'; 7 | import { DataRenderer } from '../DataRenderer'; 8 | import TreeItem from '@mui/lab/TreeItem'; 9 | import { findAffectedContract, TreeItemContentSpan } from '../helpers'; 10 | import WithSeparator from 'react-with-separator'; 11 | import { Grid } from '@mui/material'; 12 | import { TraceTreeItem, TraceTreeNodeLabel } from './TraceTreeItem'; 13 | import { TraceTreeDialog } from './TraceTreeDialog'; 14 | import { TraceEntrySstore, TraceResponse } from '../api'; 15 | 16 | type SstoreTraceTreeItemProps = { 17 | traceResult: TraceResponse; 18 | traceMetadata: TraceMetadata; 19 | storageMetadata: StorageMetadata; 20 | node: TraceEntrySstore; 21 | 22 | children?: JSX.Element[]; 23 | }; 24 | 25 | export const SstoreTraceTreeItem = (props: SstoreTraceTreeItemProps) => { 26 | const { traceResult, traceMetadata, storageMetadata, node, children } = props; 27 | 28 | const [open, setOpen] = React.useState(false); 29 | 30 | let [affectedCall] = findAffectedContract(traceMetadata, node); 31 | 32 | let ourSlots = storageMetadata.slots[affectedCall.to][affectedCall.codehash]; 33 | 34 | let variablesSorted = Object.entries(ourSlots[node.slot].variables) 35 | .sort((a, b) => parseInt(a[0]) - parseInt(b[0])) 36 | .map(([offset, variableInfo]) => { 37 | let start = parseInt(offset); 38 | let end = start + variableInfo.bits; 39 | return { 40 | name: variableInfo.fullName, 41 | start: start, 42 | end: end, 43 | type: variableInfo.typeName.typeDescriptions.typeString, 44 | }; 45 | }); 46 | 47 | if (!variablesSorted.length) { 48 | variablesSorted = [ 49 | { 50 | name: node.slot, 51 | start: 0, 52 | end: 256, 53 | type: 'bytes32', 54 | }, 55 | ]; 56 | } 57 | 58 | let vars = variablesSorted.map((v, i) => { 59 | let start = node.oldValue.length - v.end / 4; 60 | let end = node.oldValue.length - v.start / 4; 61 | return { 62 | name: v.name, 63 | oldValue: node.oldValue.substring(start, end), 64 | newValue: node.newValue.substring(start, end), 65 | type: v.type, 66 | }; 67 | }); 68 | 69 | let dialogSlotTree = ( 70 | } 73 | defaultExpandIcon={} 74 | > 75 | {renderSlotTree(ourSlots, node.slot, 'root')} 76 | 77 | ); 78 | 79 | let dialogValues = ( 80 | } 83 | defaultExpandIcon={} 84 | > 85 | {vars.map((v, i) => { 86 | let oldDataRenderer = ; 87 | let newDataRenderer = ; 88 | 89 | return ( 90 | 95 | {v.name}: {oldDataRenderer} →  96 | {newDataRenderer} 97 | 98 | } 99 | /> 100 | ); 101 | })} 102 | 103 | ); 104 | 105 | let dialogTitle = ( 106 | <> 107 | sstore  108 | , }> 109 | {variablesSorted.map((v) => { 110 | return {v.name}; 111 | })} 112 | 113 | 114 | ); 115 | 116 | let dialogContent = ( 117 | <> 118 | 119 | 120 | Trace Path: {node.path} 121 | 122 | 123 | Slot: {node.slot} 124 | 125 | 126 | Old Value: {node.oldValue} 127 | 128 | 129 | New Value: {node.newValue} 130 | 131 | Decoded slot trace: {dialogSlotTree} 132 | Decoded values: {dialogValues} 133 | 134 | 135 | ); 136 | 137 | let treeContent = ( 138 | <> 139 | setOpen(true)} /> 140 |   141 | , }> 142 | {vars.map((v, i) => { 143 | let oldDataRenderer = ; 144 | let newDataRenderer = ; 145 | if (v.oldValue === v.newValue) { 146 | return ( 147 | 153 | {v.name}: {oldDataRenderer} 154 |  → (unchanged) 155 | 156 | ); 157 | } else { 158 | return ( 159 | 160 | {v.name}: {oldDataRenderer} →  161 | {newDataRenderer} 162 | 163 | ); 164 | } 165 | })} 166 | 167 | 168 | ); 169 | 170 | return ( 171 | <> 172 | 173 | 174 | {children} 175 | 176 | 177 | ); 178 | }; 179 | -------------------------------------------------------------------------------- /components/trace/TraceTreeDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Dialog, DialogContent, DialogTitle, Grid, Paper, PaperProps, Typography } from '@mui/material'; 3 | import Draggable, { DraggableData, DraggableEvent, DraggableEventHandler } from 'react-draggable'; 4 | 5 | const DraggablePaperContext = React.createContext({ 6 | position: { x: 0, y: 0 }, 7 | onStop: undefined as DraggableEventHandler | undefined, 8 | }); 9 | 10 | const DraggablePaper = (props: PaperProps) => { 11 | const { position, onStop } = React.useContext(DraggablePaperContext); 12 | 13 | const nodeRef = React.useRef(null); 14 | 15 | return ( 16 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | type TraceTreeDialogProps = { 29 | title: JSX.Element; 30 | content: JSX.Element; 31 | 32 | open: boolean; 33 | setOpen: React.Dispatch>; 34 | }; 35 | 36 | export const TraceTreeDialog = (props: TraceTreeDialogProps) => { 37 | const { title, content, open, setOpen } = props; 38 | 39 | const [position, setPosition] = React.useState({ x: 0, y: 0 }); 40 | 41 | return ( 42 | { 46 | setPosition({ x: dragElement.x, y: dragElement.y }); 47 | }, 48 | }} 49 | > 50 | {}} 53 | PaperComponent={DraggablePaper} 54 | PaperProps={{ 55 | sx: { 56 | pointerEvents: 'all', 57 | }, 58 | }} 59 | hideBackdrop={true} 60 | disableScrollLock={true} 61 | disableEnforceFocus={true} 62 | maxWidth={'md'} 63 | > 64 | 65 | 66 | 67 | 68 | 69 | {title} 70 | 71 | 72 | 73 | setOpen(false)}> 74 | [X] 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | {content} 83 | 84 | 85 | 86 | 87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /components/trace/TraceTreeItem.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import TreeItem from '@mui/lab/TreeItem'; 3 | import { TreeItemContentSpan } from '../helpers'; 4 | import { Property } from 'csstype'; 5 | import Color = Property.Color; 6 | 7 | type TraceTreeItemProps = { 8 | nodeId: string; 9 | 10 | treeContent: JSX.Element | JSX.Element[]; 11 | 12 | children?: JSX.Element[]; 13 | }; 14 | 15 | type TraceTreeNodeLabelProps = { 16 | nodeType: string; 17 | nodeColor: Color; 18 | onNodeClick?: React.MouseEventHandler; 19 | }; 20 | 21 | export const TraceTreeNodeLabel = (props: TraceTreeNodeLabelProps) => { 22 | const { nodeType, nodeColor, onNodeClick } = props; 23 | 24 | return ( 25 | 32 | [{nodeType}] 33 | 34 | ); 35 | }; 36 | 37 | export const TraceTreeItem = (props: TraceTreeItemProps) => { 38 | const { nodeId, treeContent, children } = props; 39 | 40 | return ( 41 | {treeContent}} 48 | > 49 | {children} 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /components/transaction-info/TransactionInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Provider } from '@ethersproject/providers'; 2 | import { Grid, Tooltip, Typography } from '@mui/material'; 3 | import { BigNumber, ethers } from 'ethers'; 4 | import { formatUnits, getContractAddress } from 'ethers/lib/utils'; 5 | import humanizeDuration from 'humanize-duration'; 6 | import { DateTime } from 'luxon'; 7 | import * as React from 'react'; 8 | import { useContext } from 'react'; 9 | import { ChainConfigContext } from '../Chains'; 10 | import { DataRenderer } from '../DataRenderer'; 11 | import { GasPriceEstimator } from '../gas-price-estimator/estimate'; 12 | import { formatUnitsSmartly, formatUsd } from '../helpers'; 13 | import { PriceMetadataContext } from '../metadata/prices'; 14 | import { TransactionMetadataContext } from '../metadata/transaction'; 15 | 16 | type TransactionAttributeGridProps = { 17 | children?: React.ReactNode[]; 18 | }; 19 | 20 | export const TransactionAttributeGrid = (props: TransactionAttributeGridProps) => { 21 | return ( 22 | 23 | {props.children} 24 | 25 | ); 26 | }; 27 | 28 | type TransactionAttributeRowProps = { 29 | children?: React.ReactNode | React.ReactNode[]; 30 | }; 31 | 32 | export const TransactionAttributeRow = (props: TransactionAttributeRowProps) => { 33 | return ( 34 | 35 | {props.children} 36 | 37 | ); 38 | }; 39 | 40 | type TransactionAttributeProps = { 41 | name: string; 42 | 43 | children?: React.ReactNode | React.ReactNode[]; 44 | }; 45 | 46 | export const TransactionAttribute = (props: TransactionAttributeProps) => { 47 | return ( 48 | 49 | {props.name}: {props.children} 50 | 51 | ); 52 | }; 53 | 54 | type TransactionInfoProps = { 55 | estimator: GasPriceEstimator; 56 | provider: Provider; 57 | }; 58 | 59 | export const TransactionInfo = (props: TransactionInfoProps) => { 60 | console.time('render transaction info'); 61 | const transactionMetadata = useContext(TransactionMetadataContext); 62 | const chainConfig = useContext(ChainConfigContext); 63 | const priceMetadata = useContext(PriceMetadataContext); 64 | 65 | const [estimatedConfirmation, setEstimatedConfirmation] = 66 | React.useState<['below_base_fee' | 'below_worst_tx' | 'nonce_too_high' | null, number]>(); 67 | 68 | React.useMemo(() => { 69 | if (transactionMetadata.result === null) { 70 | props.estimator.start(() => { 71 | const estimationResult = props.estimator.estimate(transactionMetadata.transaction); 72 | console.log('estimated', estimationResult); 73 | if (estimationResult[0] === null) { 74 | props.provider 75 | .getTransactionCount(transactionMetadata.transaction.from) 76 | .then((nonce) => { 77 | if (transactionMetadata.transaction.nonce > nonce) { 78 | setEstimatedConfirmation(['nonce_too_high', -1]); 79 | } else { 80 | console.log('setting confirmation to', estimationResult); 81 | setEstimatedConfirmation(estimationResult); 82 | } 83 | }) 84 | .catch((e) => { 85 | console.log('failed to get nonce', e); 86 | setEstimatedConfirmation(estimationResult); 87 | }); 88 | } else { 89 | setEstimatedConfirmation(estimationResult); 90 | } 91 | }); 92 | } else { 93 | props.estimator.stop(); 94 | } 95 | }, [transactionMetadata.result]); 96 | 97 | let transactionStatus: string; 98 | if (transactionMetadata.result === null) { 99 | transactionStatus = 'Pending'; 100 | } else { 101 | if (transactionMetadata.result.receipt.status === 0) { 102 | transactionStatus = 'Failed'; 103 | } else if (transactionMetadata.result.receipt.status === 1) { 104 | transactionStatus = 'Succeeded'; 105 | } else { 106 | transactionStatus = 'Unknown'; 107 | } 108 | } 109 | const statusAttribute = {transactionStatus}; 110 | let timestampAttribute = null; 111 | let blockAttribute = null; 112 | let estimatedConfirmationAttribute = null; 113 | if (transactionMetadata.result === null) { 114 | let message; 115 | console.log('confirmation is', estimatedConfirmation); 116 | if (!estimatedConfirmation) { 117 | message = 'calculating...'; 118 | } else { 119 | if (estimatedConfirmation[0] === 'below_base_fee') { 120 | message = 'never (max fee is below base fee)'; 121 | } else if (estimatedConfirmation[0] === 'below_worst_tx') { 122 | message = 'a very long time (max fee is below cheapest txs)'; 123 | } else if (estimatedConfirmation[0] === 'nonce_too_high') { 124 | message = 'unknown (blocked by a transaction with a lower nonce)'; 125 | } else { 126 | const numBlocks = Math.round(estimatedConfirmation[1]); 127 | if (numBlocks === 0) { 128 | message = 'any second now'; 129 | } else { 130 | const lowerBound = (numBlocks - 1) * 15 * 1000; 131 | const upperBound = numBlocks * 15 * 1000; 132 | message = 'between ' + humanizeDuration(lowerBound) + ' and ' + humanizeDuration(upperBound); 133 | } 134 | } 135 | } 136 | 137 | estimatedConfirmationAttribute = ( 138 | {message} 139 | ); 140 | } else { 141 | let blockTimestamp = DateTime.fromSeconds(transactionMetadata.result.timestamp); 142 | 143 | let localTime = blockTimestamp.toFormat('yyyy-MM-dd hh:mm:ss ZZZZ'); 144 | let utcTime = blockTimestamp.toUTC().toFormat('yyyy-MM-dd hh:mm:ss ZZZZ'); 145 | let timeSince = humanizeDuration(DateTime.now().toMillis() - blockTimestamp.toMillis(), { largest: 2 }); 146 | 147 | timestampAttribute = ( 148 | 149 | 150 | {localTime} 151 | 152 |  ({timeSince} ago) 153 | 154 | ); 155 | 156 | blockAttribute = ( 157 | 158 | 163 | {transactionMetadata.result.receipt.blockNumber} 164 | 165 | 166 | ); 167 | } 168 | 169 | const toAddress = transactionMetadata.transaction.to || getContractAddress(transactionMetadata.transaction); 170 | 171 | const fromAttribute = ( 172 | 173 | 174 | 175 | ); 176 | 177 | const toAttribute = ( 178 | 179 | 180 | 181 | ); 182 | 183 | let gasLimit = transactionMetadata.transaction.gasLimit.toBigInt(); 184 | let gasPrice = 0n; 185 | if (transactionMetadata.transaction.gasPrice) { 186 | gasPrice = transactionMetadata.transaction.gasPrice.toBigInt(); 187 | } else { 188 | if (transactionMetadata.transaction.maxFeePerGas) { 189 | gasPrice += transactionMetadata.transaction.maxFeePerGas.toBigInt(); 190 | } 191 | if (transactionMetadata.transaction.maxPriorityFeePerGas) { 192 | gasPrice += transactionMetadata.transaction.maxPriorityFeePerGas.toBigInt(); 193 | } 194 | } 195 | 196 | if (transactionMetadata.result !== null) { 197 | // update with actual values 198 | gasLimit = transactionMetadata.result.receipt.gasUsed.toBigInt(); 199 | if (transactionMetadata.result.receipt.effectiveGasPrice) { 200 | gasPrice = transactionMetadata.result.receipt.effectiveGasPrice.toBigInt(); 201 | } 202 | } 203 | 204 | const transactionValue = transactionMetadata.transaction.value.toBigInt(); 205 | const transactionFee = gasLimit * gasPrice; 206 | 207 | let transactionValueStr = formatUnitsSmartly(transactionValue, chainConfig.nativeSymbol); 208 | let transactionFeeStr = formatUnitsSmartly(transactionFee, chainConfig.nativeSymbol); 209 | 210 | let transactionValueUSD; 211 | let transactionFeeUSD; 212 | 213 | const historicalEthPrice = priceMetadata.prices[chainConfig.coingeckoId]?.historicalPrice; 214 | const currentEthPrice = priceMetadata.prices[chainConfig.coingeckoId]?.currentPrice; 215 | if (historicalEthPrice) { 216 | transactionValueUSD = ( 217 | <> 218 |  ( 219 | 226 | {formatUsd(transactionValue * historicalEthPrice)} 227 | 228 | ) 229 | 230 | ); 231 | transactionFeeUSD = ( 232 | <> 233 |  ( 234 | 241 | {formatUsd(transactionFee * historicalEthPrice)} 242 | 243 | ) 244 | 245 | ); 246 | } 247 | 248 | const valueAttribute = ( 249 | 250 | {transactionValueStr} 251 | {transactionValueUSD} 252 | 253 | ); 254 | const feeAttribute = ( 255 | 258 | {transactionFeeStr} 259 | {transactionFeeUSD} 260 | 261 | ); 262 | 263 | let gasUsedAttribute; 264 | if (transactionMetadata.result !== null) { 265 | gasUsedAttribute = ( 266 | 267 | {transactionMetadata.result.receipt.gasUsed.toString()}/ 268 | {transactionMetadata.transaction.gasLimit.toString()} ( 269 | {( 270 | (transactionMetadata.result.receipt.gasUsed.toNumber() * 100) / 271 | transactionMetadata.transaction.gasLimit.toNumber() 272 | ).toPrecision(4)} 273 | %) 274 | 275 | ); 276 | } else { 277 | gasUsedAttribute = ( 278 | 279 | {transactionMetadata.transaction.gasLimit.toString()} 280 | 281 | ); 282 | } 283 | let gasPriceAttribute; 284 | if (transactionMetadata.transaction.type === 2) { 285 | gasPriceAttribute = ( 286 | <> 287 | {transactionMetadata.result != null ? ( 288 | 289 | {formatUnits(transactionMetadata.result.receipt.effectiveGasPrice, 'gwei')} gwei 290 | 291 | ) : null} 292 | 293 | {formatUnits(transactionMetadata.transaction.maxPriorityFeePerGas!, 'gwei')} gwei 294 | 295 | 296 | {formatUnits(transactionMetadata.transaction.maxFeePerGas!, 'gwei')} gwei 297 | 298 | 299 | ); 300 | } else { 301 | gasPriceAttribute = ( 302 | <> 303 | 304 | {formatUnits(gasPrice, 'gwei')} gwei 305 | 306 | 307 | ); 308 | } 309 | 310 | let calldataAsUtf8; 311 | try { 312 | const data = transactionMetadata.transaction.data.replace(/(00)+$/g, ''); 313 | const utf8Str = ethers.utils.toUtf8String(data).trim(); 314 | if (utf8Str.length > 0) { 315 | calldataAsUtf8 = ( 316 | 317 | 318 |
319 | {utf8Str} 320 |
321 |
322 | ); 323 | } 324 | } catch {} 325 | 326 | const result = ( 327 | <> 328 | 329 | 330 | 331 | {statusAttribute} 332 | {estimatedConfirmationAttribute} 333 | {timestampAttribute} 334 | {blockAttribute} 335 | 336 | 337 | {fromAttribute} 338 | {toAttribute} 339 | 340 | 341 | {valueAttribute} 342 | {feeAttribute} 343 | 344 | 345 | {gasUsedAttribute} 346 | {gasPriceAttribute} 347 | 348 | 349 | 350 | {transactionMetadata.transaction.nonce} 351 | 352 | 353 | {transactionMetadata.transaction.type === 2 354 | ? 'EIP-1559' 355 | : transactionMetadata.transaction.type === 1 356 | ? 'Access List' 357 | : 'Legacy'} 358 | 359 | {transactionMetadata.result !== null ? ( 360 | 361 | {transactionMetadata.result.receipt.transactionIndex} 362 | 363 | ) : null} 364 | 365 | {calldataAsUtf8} 366 | 367 | 368 | 369 | ); 370 | console.timeEnd('render transaction info'); 371 | return result; 372 | }; 373 | -------------------------------------------------------------------------------- /components/types.tsx: -------------------------------------------------------------------------------- 1 | import { ethers } from 'ethers'; 2 | import { TraceEntry, TraceEntryCall } from './api'; 3 | 4 | export type TransactionTrace = { 5 | txhash: string; 6 | entrypoint: TraceEntryCall; 7 | nodesByPath: Record; 8 | preimages: Record; 9 | }; 10 | 11 | export type TraceMetadata = { 12 | // map of address => codehash => abi 13 | abis: Record>; 14 | 15 | nodesByPath: Record; 16 | }; 17 | 18 | export type TypeDescriptions = { 19 | typeIdentifier: string; 20 | typeString: string; 21 | }; 22 | 23 | export type TypeName = { 24 | nodeType: string; 25 | typeDescriptions: TypeDescriptions; 26 | keyType: TypeName; 27 | valueType: TypeName; 28 | }; 29 | 30 | export type VariableInfo = { 31 | name: string; 32 | fullName: string | JSX.Element; 33 | typeName: TypeName; 34 | bits: number; 35 | }; 36 | 37 | export type BaseSlotInfo = { 38 | resolved: boolean; 39 | // map of offset => variable 40 | variables: Record; 41 | }; 42 | 43 | export type RawSlotInfo = BaseSlotInfo & { 44 | type: 'raw'; 45 | }; 46 | 47 | export type DynamicSlotInfo = BaseSlotInfo & { 48 | type: 'dynamic'; 49 | 50 | baseSlot: string; 51 | key: string; 52 | offset: number; 53 | }; 54 | 55 | export type MappingSlotInfo = BaseSlotInfo & { 56 | type: 'mapping'; 57 | 58 | baseSlot: string; 59 | 60 | mappingKey: string; 61 | 62 | offset: number; 63 | }; 64 | 65 | export type ArraySlotInfo = BaseSlotInfo & { 66 | type: 'array'; 67 | 68 | baseSlot: string; 69 | 70 | offset: number; 71 | }; 72 | 73 | export type StructSlotInfo = BaseSlotInfo & { 74 | type: 'struct'; 75 | offset: number; 76 | }; 77 | 78 | export type SlotInfo = RawSlotInfo | DynamicSlotInfo | MappingSlotInfo | ArraySlotInfo | StructSlotInfo; 79 | 80 | export type StorageMetadata = { 81 | fetched: Record>>; 82 | 83 | slots: Record>>; 84 | }; 85 | 86 | export type ErrorResult = { 87 | ok: false; 88 | error: any; 89 | }; 90 | 91 | export type SuccessResult = { 92 | ok: true; 93 | result: T; 94 | }; 95 | 96 | export type Result = ErrorResult | SuccessResult; 97 | -------------------------------------------------------------------------------- /components/value-change/ValueChange.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Collapse, Table, TableBody, TableCell, TableHead, TableRow, TableSortLabel } from '@mui/material'; 2 | import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; 3 | import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; 4 | import { TraceMetadata } from '../types'; 5 | import React, { useContext } from 'react'; 6 | import { SpanIconButton } from '../SpanIconButton'; 7 | import { BigNumber, ethers } from 'ethers'; 8 | import { findAffectedContract, formatUsd } from '../helpers'; 9 | import { DataRenderer } from '../DataRenderer'; 10 | import { ChainConfig, ChainConfigContext } from '../Chains'; 11 | import { 12 | fetchDefiLlamaPrices, 13 | getPriceOfToken, 14 | PriceMetadata, 15 | PriceMetadataContext, 16 | toDefiLlamaId, 17 | } from '../metadata/prices'; 18 | import { fetchTokenMetadata, TokenMetadata, TokenMetadataContext } from '../metadata/tokens'; 19 | import { TraceEntryCall, TraceEntryLog, TraceResponse } from '../api'; 20 | import { BaseProvider } from '@ethersproject/providers'; 21 | import { TransactionMetadataContext } from '../metadata/transaction'; 22 | 23 | const NATIVE_TOKEN = 'native_token'; 24 | 25 | type AddressValueInfo = { 26 | hasMissingPrices: boolean; 27 | totalValueChange: bigint; 28 | changePerToken: Record; 29 | }; 30 | 31 | export type ValueChangeProps = { 32 | traceResult: TraceResponse; 33 | traceMetadata: TraceMetadata; 34 | provider: BaseProvider; 35 | }; 36 | 37 | type RowProps = { 38 | address: string; 39 | changes: AddressValueInfo; 40 | }; 41 | 42 | function Row(props: RowProps) { 43 | const { address, changes: valueInfo } = props; 44 | 45 | const priceMetadata = useContext(PriceMetadataContext); 46 | const tokenMetadata = useContext(TokenMetadataContext); 47 | const chainConfig = useContext(ChainConfigContext); 48 | 49 | const [open, setOpen] = React.useState(false); 50 | 51 | const changeInPriceRendered = valueInfo.hasMissingPrices ? ( 52 | Loading... 53 | ) : ( 54 | 0n ? '#067034' : '', 57 | }} 58 | > 59 | {formatUsd(valueInfo.totalValueChange)} 60 | 61 | ); 62 | 63 | const tokenBreakdown = Object.keys(valueInfo.changePerToken) 64 | .sort() 65 | .map((token) => { 66 | let labels; 67 | let tokenAddress = token; 68 | let priceId = toDefiLlamaId(chainConfig, token); 69 | if (token === NATIVE_TOKEN) { 70 | tokenAddress = chainConfig.nativeTokenAddress || ''; 71 | priceId = chainConfig.coingeckoId || ''; 72 | labels = { [tokenAddress]: chainConfig.nativeSymbol || '' }; 73 | } 74 | tokenAddress = tokenAddress.toLowerCase(); 75 | 76 | let amountFormatted = valueInfo.changePerToken[token].toString(); 77 | let tokenPriceRendered = 'Loading...'; 78 | 79 | let tokenInfo = tokenMetadata.tokens[tokenAddress]; 80 | if (tokenInfo !== undefined && tokenInfo.decimals !== undefined) { 81 | amountFormatted = ethers.utils.formatUnits(valueInfo.changePerToken[token], tokenInfo.decimals); 82 | } 83 | if (priceMetadata.status[priceId] === 'fetched') { 84 | tokenPriceRendered = formatUsd( 85 | getPriceOfToken(priceMetadata, priceId, valueInfo.changePerToken[token], 'historical')!, 86 | ); 87 | } 88 | 89 | return ( 90 | 91 | 92 | {} 93 | 94 | {amountFormatted} 95 | {tokenPriceRendered} 96 | 97 | ); 98 | }); 99 | 100 | return ( 101 | 102 | 103 | 104 | setOpen(!open)} 107 | /> 108 | 109 | 110 | 111 | 112 | 113 | {changeInPriceRendered} 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | Token 124 | Amount 125 | Value 126 | 127 | 128 | {tokenBreakdown} 129 |
130 |
131 |
132 |
133 |
134 |
135 | ); 136 | } 137 | 138 | const computeBalanceChanges = ( 139 | entrypoint: TraceEntryCall, 140 | traceMetadata: TraceMetadata, 141 | tokenMetadata: TokenMetadata, 142 | chainConfig: ChainConfig, 143 | priceMetadata: PriceMetadata, 144 | ): [Record, Set] => { 145 | const changes: Record = {}; 146 | const allTokens = new Set(); 147 | 148 | const addChange = (address: string, token: string, change: bigint) => { 149 | address = address.toLowerCase(); 150 | token = token.toLowerCase(); 151 | 152 | allTokens.add(token); 153 | 154 | if (tokenMetadata.status[token] === 'fetched' && tokenMetadata.tokens[token].isNft) { 155 | change = change > 0n ? 1n : -1n; 156 | } 157 | 158 | if (!(address in changes)) { 159 | changes[address] = { 160 | hasMissingPrices: false, 161 | totalValueChange: 0n, 162 | changePerToken: {}, 163 | }; 164 | } 165 | if (!(token in changes[address].changePerToken)) { 166 | changes[address].changePerToken[token] = change; 167 | return; 168 | } 169 | 170 | changes[address].changePerToken[token] = changes[address].changePerToken[token] + change; 171 | }; 172 | 173 | const visitNode = (node: TraceEntryCall) => { 174 | // skip failed calls because their events don't matter 175 | if (node.status === 0) return; 176 | 177 | const value = BigNumber.from(node.value).toBigInt(); 178 | if (value != 0n) { 179 | addChange(node.from, NATIVE_TOKEN, -value); 180 | addChange(node.to, NATIVE_TOKEN, value); 181 | } 182 | 183 | node.children 184 | .filter((child): child is TraceEntryLog => child.type === 'log') 185 | .forEach((traceLog) => { 186 | if (traceLog.topics.length === 0) return; 187 | if (traceLog.topics[0] === '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef') { 188 | const [parentNode] = findAffectedContract(traceMetadata, traceLog); 189 | 190 | try { 191 | const parsedEvent = traceMetadata.abis[node.to][node.codehash].parseLog({ 192 | topics: traceLog.topics, 193 | data: traceLog.data, 194 | }); 195 | 196 | const value = (parsedEvent.args[2] as BigNumber).toBigInt(); 197 | addChange(parsedEvent.args[0] as string, parentNode.to, -value); 198 | addChange(parsedEvent.args[1] as string, parentNode.to, value); 199 | } catch (e) { 200 | console.error('failed to process value change', e); 201 | } 202 | } else if ( 203 | traceLog.topics[0] === '0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65' 204 | ) { 205 | const [parentNode] = findAffectedContract(traceMetadata, traceLog); 206 | 207 | try { 208 | const parsedEvent = traceMetadata.abis[node.to][node.codehash].parseLog({ 209 | topics: traceLog.topics, 210 | data: traceLog.data, 211 | }); 212 | 213 | const value = (parsedEvent.args[1] as BigNumber).toBigInt(); 214 | addChange(parsedEvent.args[0] as string, parentNode.to, -value); 215 | } catch (e) { 216 | console.error('failed to process value change', e); 217 | } 218 | } 219 | }); 220 | 221 | node.children.filter((child): child is TraceEntryCall => child.type === 'call').forEach(visitNode); 222 | }; 223 | visitNode(entrypoint); 224 | 225 | for (let [addr, addrChanges] of Object.entries(changes)) { 226 | for (let [token, delta] of Object.entries(addrChanges)) { 227 | if (delta === 0n) { 228 | delete addrChanges.changePerToken[token]; 229 | } 230 | } 231 | 232 | if (Object.entries(addrChanges).length === 0) { 233 | delete changes[addr]; 234 | } 235 | } 236 | 237 | Object.values(changes).forEach((info) => { 238 | let hasMissingPrice = false; 239 | let changeInValue = 0n; 240 | Object.entries(info.changePerToken).forEach(([token, delta]) => { 241 | const defiLlamaId = toDefiLlamaId(chainConfig, token); 242 | 243 | const deltaPrice = getPriceOfToken(priceMetadata, defiLlamaId, delta, 'historical'); 244 | if (deltaPrice === null) { 245 | hasMissingPrice = true; 246 | return; 247 | } 248 | 249 | changeInValue += deltaPrice; 250 | }); 251 | 252 | info.hasMissingPrices = hasMissingPrice; 253 | info.totalValueChange = changeInValue; 254 | }); 255 | 256 | return [changes, allTokens]; 257 | }; 258 | 259 | export const ValueChange = (props: ValueChangeProps) => { 260 | console.log('rendering value change'); 261 | const { traceResult, traceMetadata, provider } = props; 262 | const tokenMetadata = useContext(TokenMetadataContext); 263 | const chainConfig = useContext(ChainConfigContext); 264 | const transactionMetadata = useContext(TransactionMetadataContext); 265 | const priceMetadata = useContext(PriceMetadataContext); 266 | 267 | const [changes, allTokens] = React.useMemo(() => { 268 | return computeBalanceChanges(traceResult.entrypoint, traceMetadata, tokenMetadata, chainConfig, priceMetadata); 269 | }, [traceResult, traceMetadata, tokenMetadata, priceMetadata, chainConfig]); 270 | const [sortOptions, setSortOptions] = React.useState<['address' | 'price', 'asc' | 'desc']>(['price', 'desc']); 271 | 272 | if (transactionMetadata.result) { 273 | fetchDefiLlamaPrices( 274 | priceMetadata.updater, 275 | Array.from(allTokens).map((token) => { 276 | const tokenAddress = token === NATIVE_TOKEN ? ethers.constants.AddressZero : token; 277 | return `${chainConfig.defillamaPrefix}:${tokenAddress}`; 278 | }), 279 | transactionMetadata.result.timestamp, 280 | ); 281 | } 282 | fetchTokenMetadata(tokenMetadata.updater, provider, Array.from(allTokens)); 283 | 284 | return Object.entries(changes).length > 0 ? ( 285 | 286 | 287 | 288 | 289 | 290 | { 294 | setSortOptions((prevOptions) => { 295 | return [ 296 | 'address', 297 | prevOptions[0] === 'address' && prevOptions[1] === 'asc' ? 'desc' : 'asc', 298 | ]; 299 | }); 300 | }} 301 | > 302 | Address 303 | 304 | 305 | 306 | { 310 | setSortOptions((prevOptions) => { 311 | return [ 312 | 'price', 313 | prevOptions[0] === 'price' && prevOptions[1] === 'asc' ? 'desc' : 'asc', 314 | ]; 315 | }); 316 | }} 317 | > 318 | Change In Value 319 | 320 | 321 | 322 | 323 | 324 | {Object.entries(changes) 325 | .sort( 326 | sortOptions[0] === 'address' 327 | ? (a, b) => { 328 | return sortOptions[1] === 'asc' ? a[0].localeCompare(b[0]) : b[0].localeCompare(a[0]); 329 | } 330 | : (a, b) => { 331 | if (!a[1].hasMissingPrices && !b[1].hasMissingPrices) { 332 | return sortOptions[1] === 'asc' 333 | ? a[1].totalValueChange < b[1].totalValueChange 334 | ? -1 335 | : 1 336 | : b[1].totalValueChange < a[1].totalValueChange 337 | ? -1 338 | : 1; 339 | } else if (a[1].hasMissingPrices) { 340 | return sortOptions[1] === 'asc' ? -1 : 1; 341 | } else if (b[1].hasMissingPrices) { 342 | return sortOptions[1] === 'asc' ? 1 : -1; 343 | } else { 344 | return 0; 345 | } 346 | }, 347 | ) 348 | .map((entry) => { 349 | return ; 350 | })} 351 | 352 |
353 | ) : null; 354 | }; 355 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | output: 'standalone', 6 | 7 | // remove these later 8 | typescript: { 9 | ignoreBuildErrors: true, 10 | }, 11 | eslint: { 12 | ignoreDuringBuilds: true, 13 | }, 14 | }; 15 | 16 | module.exports = nextConfig; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "pretty": "prettier --write .", 11 | "test": "jest" 12 | }, 13 | "dependencies": { 14 | "@babel/core": "7.17.10", 15 | "@emotion/react": "^11.10.4", 16 | "@emotion/styled": "^11.10.4", 17 | "@ethersproject/abi": "^5.7.0", 18 | "@ethersproject/abstract-provider": "^5.7.0", 19 | "@mui/icons-material": "^5.10.3", 20 | "@mui/lab": "5.0.0-alpha.101", 21 | "@mui/material": "^5.10.3", 22 | "@mui/system": "^5.10.8", 23 | "@react-hookz/web": "^20.0.1", 24 | "@samczsun/abi-guesser": "github:samczsun/abi-guesser", 25 | "@samczsun/transaction-decoder": "github:samczsun/transaction-decoder", 26 | "csstype": "^3.1.1", 27 | "ethers": "^5.7.1", 28 | "humanize-duration": "^3.27.3", 29 | "jest": "^29.2.1", 30 | "luxon": "^3.0.4", 31 | "next": "12.2.5", 32 | "react": "18.2.0", 33 | "react-dom": "18.2.0", 34 | "react-draggable": "^4.4.5", 35 | "react-with-separator": "=1.2.1", 36 | "typescript": "^4.8.2" 37 | }, 38 | "devDependencies": { 39 | "@types/bn.js": "^5.1.1", 40 | "@types/humanize-duration": "^3.27.1", 41 | "@types/jest": "^29.2.0", 42 | "@types/luxon": "^3.0.1", 43 | "@types/node": "^18.7.15", 44 | "@types/react": "^18.0.18", 45 | "autoprefixer": "^10.4.12", 46 | "eslint": "8.23.0", 47 | "eslint-config-next": "12.2.5", 48 | "eslint-config-prettier": "^8.5.0", 49 | "postcss": "^8.4.17", 50 | "prettier": "^2.7.1", 51 | "ts-jest": "^29.0.3" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /pages/[chain]/[txhash].tsx: -------------------------------------------------------------------------------- 1 | import { TransactionReceipt } from '@ethersproject/abstract-provider'; 2 | import { getNetwork } from '@ethersproject/networks'; 3 | import { BaseProvider } from '@ethersproject/providers'; 4 | import { Launch } from '@mui/icons-material'; 5 | import { Box, Typography } from '@mui/material'; 6 | import { ethers } from 'ethers'; 7 | import { useRouter } from 'next/router'; 8 | import * as React from 'react'; 9 | import { doApiRequest, TraceEntry, TraceResponse } from '../../components/api'; 10 | import { ChainConfig, ChainConfigContext, defaultChainConfig, getChain } from '../../components/Chains'; 11 | import { DecodeTree } from '../../components/decoder-format/DecodeTree'; 12 | import { JsonRpcBatchProvider } from '../../components/ethers/json-rpc-batch-provider'; 13 | import { GasPriceEstimator } from '../../components/gas-price-estimator/estimate'; 14 | import { defaultLabelMetadata, LabelMetadata, LabelMetadataContext } from '../../components/metadata/labels'; 15 | import { 16 | defaultPreimageMetadata, 17 | PreimageMetadata, 18 | PreimageMetadataContext, 19 | } from '../../components/metadata/preimages'; 20 | import { 21 | defaultPriceMetadata, 22 | fetchDefiLlamaPrices, 23 | PriceMetadata, 24 | PriceMetadataContext, 25 | } from '../../components/metadata/prices'; 26 | import { defaultTokenMetadata, TokenMetadata, TokenMetadataContext } from '../../components/metadata/tokens'; 27 | import { TransactionMetadata, TransactionMetadataContext } from '../../components/metadata/transaction'; 28 | import { precompiles } from '../../components/precompiles'; 29 | import { TraceTree } from '../../components/trace/TraceTree'; 30 | import { TransactionInfo } from '../../components/transaction-info/TransactionInfo'; 31 | import { Result, TraceMetadata } from '../../components/types'; 32 | import { ValueChange } from '../../components/value-change/ValueChange'; 33 | 34 | export default function TransactionViewer() { 35 | const router = useRouter(); 36 | const { chain, txhash } = router.query; 37 | 38 | const [chainConfig, setChainConfig] = React.useState(defaultChainConfig()); 39 | const [provider, setProvider] = React.useState(); 40 | const [estimator, setEstimator] = React.useState(); 41 | 42 | const [transactionMetadata, setTransactionMetadata] = React.useState>(); 43 | 44 | const [traceResponse, setTraceResponse] = React.useState>(); 45 | 46 | const [preimageMetadata, setPreimageMetadata] = React.useState(defaultPreimageMetadata()); 47 | const [labelMetadata, setLabelMetadata] = React.useState(defaultLabelMetadata()); 48 | const [priceMetadata, setPriceMetadata] = React.useState(defaultPriceMetadata()); 49 | const [tokenMetadata, setTokenMetadata] = React.useState(defaultTokenMetadata()); 50 | 51 | const [traceResult, setTraceResult] = React.useState(); 52 | const [traceMetadata, setTraceMetadata] = React.useState(); 53 | 54 | React.useMemo(async () => { 55 | if (!chain || Array.isArray(chain)) return; 56 | if (!txhash || Array.isArray(txhash)) return; 57 | 58 | const chainConfig = await getChain(chain); 59 | if (!chainConfig) return; 60 | 61 | setChainConfig(chainConfig); 62 | 63 | setTokenMetadata({ 64 | ...defaultTokenMetadata(), 65 | updater: setTokenMetadata, 66 | }); 67 | setPriceMetadata({ 68 | ...defaultPriceMetadata(), 69 | updater: setPriceMetadata, 70 | }); 71 | setPreimageMetadata({ 72 | ...defaultPreimageMetadata(), 73 | updater: setPreimageMetadata, 74 | }); 75 | setTraceResult(undefined); 76 | setTransactionMetadata(undefined); 77 | 78 | const provider = new JsonRpcBatchProvider(chainConfig.rpcUrl, getNetwork(chainConfig.chainId)); 79 | setProvider(provider); 80 | 81 | const estimator = new GasPriceEstimator(provider); 82 | setEstimator(estimator); 83 | 84 | provider.getBlockNumber().catch(() => { }); 85 | 86 | const tryFetchTrace = () => { 87 | doApiRequest(`/api/v1/trace/${chain}/${txhash}`) 88 | .then((traceResponse) => { 89 | console.log('loaded trace', traceResponse); 90 | 91 | let labels: Record = {}; 92 | let customLabels: Record> = {}; 93 | try { 94 | customLabels = JSON.parse(localStorage.getItem('pref:labels') || '{}'); 95 | } catch { } 96 | if (!(chain in customLabels)) { 97 | customLabels[chain] = {}; 98 | } 99 | 100 | for (let address of Object.keys(precompiles)) { 101 | labels[address] = 'Precompile'; 102 | } 103 | 104 | let metadata: TraceMetadata = { 105 | abis: {}, 106 | nodesByPath: {}, 107 | }; 108 | 109 | let preprocess = (node: TraceEntry) => { 110 | metadata.nodesByPath[node.path] = node; 111 | 112 | if (node.type === 'call') { 113 | node.children.forEach(preprocess); 114 | } 115 | }; 116 | preprocess(traceResponse.entrypoint); 117 | 118 | for (let [address, entries] of Object.entries(traceResponse.addresses)) { 119 | metadata.abis[address] = {}; 120 | for (let [codehash, info] of Object.entries(entries)) { 121 | labels[address] = labels[address] || info.label; 122 | 123 | metadata.abis[address][codehash] = new ethers.utils.Interface([ 124 | ...Object.values(info.functions), 125 | ...Object.values(info.events), 126 | ...Object.values(info.errors).filter( 127 | (v) => 128 | !( 129 | // lmao wtf ethers 130 | ( 131 | (v.name === 'Error' && 132 | v.inputs && 133 | v.inputs.length === 1 && 134 | v.inputs[0].type === 'string') || 135 | (v.name === 'Panic' && 136 | v.inputs && 137 | v.inputs.length === 1 && 138 | v.inputs[0].type === 'uint256') 139 | ) 140 | ), 141 | ), 142 | ]); 143 | } 144 | } 145 | 146 | for (let address of Object.keys(labels)) { 147 | if (labels[address] === 'Vyper_contract') { 148 | labels[address] = `Vyper_contract (0x${address.substring(2, 6)}..${address.substring( 149 | 38, 150 | 42, 151 | )})`; 152 | } 153 | } 154 | 155 | Object.keys(labels).forEach((addr) => delete customLabels[chain][addr]); 156 | localStorage.setItem('pref:labels', JSON.stringify(customLabels)); 157 | 158 | setTraceResult(traceResponse); 159 | setTraceMetadata(metadata); 160 | setLabelMetadata({ 161 | updater: setLabelMetadata, 162 | labels: labels, 163 | customLabels: customLabels, 164 | }); 165 | setTraceResponse({ 166 | ok: true, 167 | result: traceResponse, 168 | }); 169 | }) 170 | .catch((e) => { 171 | setTraceResponse({ 172 | ok: false, 173 | error: e, 174 | }); 175 | console.log('failed to fetch trace', e); 176 | }); 177 | }; 178 | 179 | tryFetchTrace(); 180 | 181 | Promise.allSettled([ 182 | provider.getBlockNumber(),// make ethers fetch this so it gets batched (getTransactionReceipt really wants to know the confirmations) 183 | provider.getTransaction(txhash), 184 | provider.getTransactionReceipt(txhash), 185 | ]).then(([, transactionResult, receiptResult]) => { 186 | if (transactionResult.status === 'rejected') { 187 | console.log('an error occurred while loading the transaction!', e); 188 | 189 | setTransactionMetadata({ 190 | ok: false, 191 | error: transactionResult.reason, 192 | }); 193 | return; 194 | } 195 | 196 | if (!transactionResult.value) { 197 | setTransactionMetadata({ 198 | ok: false, 199 | error: new Error("transaction not found"), 200 | }); 201 | return; 202 | } 203 | 204 | const result: TransactionMetadata = { 205 | transaction: transactionResult.value, 206 | result: null, 207 | }; 208 | 209 | const processReceipt = (receipt: TransactionReceipt) => { 210 | console.log('got receipt', receipt); 211 | result.result = { 212 | receipt: receipt, 213 | timestamp: Math.floor(new Date().getTime() / 1000), 214 | }; 215 | 216 | setTransactionMetadata({ 217 | ok: true, 218 | result: result, 219 | }); 220 | 221 | if (receipt.confirmations > 2) { 222 | provider 223 | .getBlock(receipt.blockHash) 224 | .then((block) => { 225 | result.result!.timestamp = block.timestamp; 226 | 227 | setTransactionMetadata({ 228 | ok: true, 229 | result: result, 230 | }); 231 | 232 | fetchDefiLlamaPrices( 233 | setPriceMetadata, 234 | [chainConfig.coingeckoId], 235 | block.timestamp, 236 | ).catch((e) => { 237 | console.log('failed to fetch price', e); 238 | }); 239 | }) 240 | .catch(() => { }); 241 | } 242 | 243 | tryFetchTrace(); 244 | }; 245 | 246 | if (receiptResult.status === 'fulfilled' && receiptResult.value) { 247 | processReceipt(receiptResult.value); 248 | } else { 249 | provider 250 | .waitForTransaction(txhash) 251 | .then(processReceipt) 252 | .catch((e) => { 253 | console.log('error while waiting for receipt', e); 254 | }); 255 | } 256 | 257 | setTransactionMetadata({ 258 | ok: true, 259 | result: result, 260 | }); 261 | }) 262 | }, [chain, txhash]); 263 | 264 | let transactionInfoGrid; 265 | if (transactionMetadata) { 266 | if (transactionMetadata.ok) { 267 | transactionInfoGrid = ( 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | ); 278 | } else { 279 | transactionInfoGrid = <>Failed to fetch transaction: {transactionMetadata.error.toString()}; 280 | } 281 | } 282 | 283 | let valueChanges; 284 | if (transactionMetadata && traceResult && traceMetadata && provider) { 285 | if (transactionMetadata.ok) { 286 | valueChanges = ( 287 | 288 | 289 | 290 | 291 | 292 | 297 | 298 | 299 | 300 | 301 | 302 | ); 303 | } else { 304 | transactionInfoGrid = <>Failed to fetch transaction or trace; 305 | } 306 | } 307 | 308 | let transactionActions; 309 | if (transactionMetadata && traceResult && traceMetadata && provider) { 310 | if (transactionMetadata.ok) { 311 | transactionActions = ( 312 | 313 | 314 | 315 | 316 | 317 | 322 | 323 | 324 | 325 | 326 | 327 | ); 328 | } else { 329 | transactionActions = <>Failed to fetch transaction; 330 | } 331 | } 332 | 333 | let traceTree; 334 | if (traceResult && traceMetadata) { 335 | traceTree = ( 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | ); 344 | } 345 | 346 | let content; 347 | if (!transactionMetadata) { 348 | content = ( 349 | <> 350 | Loading transaction... 351 | 352 | ); 353 | } else { 354 | content = ( 355 | <> 356 | {transactionInfoGrid ? ( 357 | <> 358 | 359 | Transaction Info{' '} 360 | 361 | 362 | 363 | 364 | {transactionInfoGrid} 365 | 366 | ) : null} 367 | 368 | {valueChanges ? ( 369 | <> 370 | Value Changes 371 | {valueChanges} 372 | 373 | ) : null} 374 | 375 | {transactionActions ? ( 376 | <> 377 | Decoded Actions 378 | 390 | {transactionActions} 391 | 392 | 393 | ) : null} 394 | 395 | {traceTree ? ( 396 | <> 397 | Call Trace 398 | 410 | {traceTree} 411 | 412 | 413 | ) : null} 414 | 415 | ); 416 | } 417 | 418 | return ( 419 | 420 | {content} 421 | 422 | ); 423 | } 424 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import Navbar from '../components/Navbar'; 3 | import { CssBaseline, useMediaQuery } from '@mui/material'; 4 | import * as React from 'react'; 5 | // noinspection ES6UnusedImports 6 | import { } from '@mui/lab/themeAugmentation'; 7 | import { ThemeProvider } from '@mui/material'; 8 | 9 | import { createTheme } from '@mui/material'; 10 | 11 | import { useLocalStorageValue } from '@react-hookz/web'; 12 | 13 | function MyApp({ Component, pageProps }: { Component: any; pageProps: any }) { 14 | const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); 15 | const { value: darkMode, set: setDarkMode } = useLocalStorageValue('pref:dark', { 16 | initializeWithValue: false, 17 | }); 18 | 19 | const shouldUseDarkMode = darkMode === undefined ? prefersDarkMode : darkMode; 20 | 21 | React.useEffect(() => { 22 | document.documentElement.setAttribute('data-theme', shouldUseDarkMode ? 'dark' : 'light'); 23 | }, [shouldUseDarkMode]); 24 | 25 | const theme = React.useMemo(() => { 26 | return createTheme({ 27 | palette: { 28 | mode: shouldUseDarkMode ? 'dark' : 'light', 29 | }, 30 | components: { 31 | MuiDialogTitle: { 32 | styleOverrides: { 33 | root: { 34 | paddingBottom: '6px', 35 | }, 36 | }, 37 | }, 38 | MuiDialogContent: { 39 | styleOverrides: { 40 | root: { 41 | paddingTop: '6px', 42 | }, 43 | }, 44 | }, 45 | MuiTreeView: { 46 | styleOverrides: { 47 | root: { 48 | // disabling this for now - if the tree is responsive then the scrollbar is at the bottom of the trace 49 | // this makes it really annoying to scroll left/right if the trace is super long, because you have to go 50 | // all the way down to the scrollbar 51 | // overflow: 'auto', 52 | // paddingBottom: '15px', // so the scrollbar doesn't cover the last trace item 53 | }, 54 | }, 55 | }, 56 | MuiTreeItem: { 57 | styleOverrides: { 58 | content: { 59 | cursor: 'initial', 60 | }, 61 | label: { 62 | fontSize: 'initial', 63 | }, 64 | iconContainer: { 65 | cursor: 'pointer', 66 | }, 67 | }, 68 | }, 69 | MuiDialog: { 70 | styleOverrides: { 71 | root: { 72 | pointerEvents: 'none', 73 | }, 74 | }, 75 | }, 76 | MuiTypography: { 77 | styleOverrides: { 78 | h5: { 79 | fontFamily: 'monospace', 80 | fontSize: 'initial', 81 | whiteSpace: 'nowrap', 82 | }, 83 | h6: { 84 | fontFamily: 'NBInter', 85 | }, 86 | body1: { 87 | fontFamily: 'monospace', 88 | wordWrap: 'break-word', 89 | whiteSpace: 'break-spaces', 90 | }, 91 | body2: { 92 | fontFamily: 'monospace', 93 | letterSpacing: 'initial', 94 | }, 95 | }, 96 | }, 97 | MuiTableCell: { 98 | styleOverrides: { 99 | root: { 100 | padding: '0px 16px', 101 | fontFamily: 'monospace', 102 | letterSpacing: 'initial', 103 | fontSize: '13px', 104 | }, 105 | // head: { 106 | // fontFamily: 'monospace', 107 | // letterSpacing: 'initial', 108 | // }, 109 | // body: { 110 | // }, 111 | }, 112 | }, 113 | }, 114 | }); 115 | }, [shouldUseDarkMode]); 116 | 117 | return ( 118 | <> 119 | 120 | 121 | setDarkMode(v)} /> 122 | 123 | 124 | 125 | ); 126 | } 127 | 128 | export default MyApp; 129 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export default function Home() { 4 | return <>; 5 | } 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | trailingComma: 'all', 4 | useTabs: false, 5 | tabWidth: 4, 6 | semi: true, 7 | singleQuote: true, 8 | bracketSpacing: true, 9 | endOfLine: 'lf', 10 | }; 11 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openchainxyz/ethereum-transaction-viewer-frontend/dd7752b5c76deff94bc0f0285c5d07ff4c5b9a5e/public/favicon.png -------------------------------------------------------------------------------- /public/fonts/NBInternational.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openchainxyz/ethereum-transaction-viewer-frontend/dd7752b5c76deff94bc0f0285c5d07ff4c5b9a5e/public/fonts/NBInternational.woff2 -------------------------------------------------------------------------------- /public/fonts/RiformaLLSub.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openchainxyz/ethereum-transaction-viewer-frontend/dd7752b5c76deff94bc0f0285c5d07ff4c5b9a5e/public/fonts/RiformaLLSub.woff2 -------------------------------------------------------------------------------- /public/images/fullscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openchainxyz/ethereum-transaction-viewer-frontend/dd7752b5c76deff94bc0f0285c5d07ff4c5b9a5e/public/images/fullscreen.png -------------------------------------------------------------------------------- /public/images/github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openchainxyz/ethereum-transaction-viewer-frontend/dd7752b5c76deff94bc0f0285c5d07ff4c5b9a5e/public/images/github.png -------------------------------------------------------------------------------- /public/images/minimize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openchainxyz/ethereum-transaction-viewer-frontend/dd7752b5c76deff94bc0f0285c5d07ff4c5b9a5e/public/images/minimize.png -------------------------------------------------------------------------------- /public/images/twitter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openchainxyz/ethereum-transaction-viewer-frontend/dd7752b5c76deff94bc0f0285c5d07ff4c5b9a5e/public/images/twitter.png -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, 65 | monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | 118 | @media (prefers-color-scheme: dark) { 119 | .card, 120 | .footer { 121 | border-color: #222; 122 | } 123 | 124 | .code { 125 | background: #111; 126 | } 127 | 128 | .logo img { 129 | filter: invert(1); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: RiformaLL; 3 | src: url('/fonts/RiformaLLSub.woff2'); 4 | font-style: normal; 5 | font-weight: 400; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: NBInter; 11 | src: url('/fonts/NBInternational.woff2'); 12 | font-style: normal; 13 | font-weight: 400; 14 | font-display: swap; 15 | } 16 | 17 | html, 18 | body { 19 | padding: 0; 20 | margin: 0; 21 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, 22 | Helvetica Neue, sans-serif; 23 | } 24 | 25 | .font-inter { 26 | font-family: 'NBInter', serif; 27 | } 28 | 29 | a { 30 | color: inherit; 31 | text-decoration: none; 32 | } 33 | 34 | * { 35 | box-sizing: border-box; 36 | } 37 | 38 | :root[data-theme='dark'] img { 39 | filter: invert(1); 40 | } 41 | 42 | img:hover { 43 | opacity: 0.6; 44 | } 45 | 46 | svg:hover { 47 | opacity: 0.6; 48 | } 49 | 50 | @media (prefers-color-scheme: dark) { 51 | html { 52 | color-scheme: dark; 53 | } 54 | 55 | body { 56 | color: white; 57 | background: black; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests.txt: -------------------------------------------------------------------------------- 1 | here are some transactions i found to help test stuff 2 | maybe i should be using jest or something lol 3 | 4 | 0x298bfef7b02c175195818389cd09d0267b324ffea29833212edd23cab50a7b20 5 | - pre-byzantium tx which failed 6 | - test the status field in transaction info 7 | 8 | 0x9ef7a35012286fef17da12624aa124ebc785d9e7621e1fd538550d1209eb9f7d 9 | - found by bunny 10 | - the ux should not lag when loading/interacting with this tx 11 | - should load under 2s max 12 | 13 | 0xdfc76788b13ab1c033c7cd55fdb7a431b2bc8abe6b19ac9f7d22f4105bb43bff 14 | - creates a contract at the top level 15 | - test transaction info and trace 16 | 17 | 18 | 0xf4a32f9a41616641baab98e8e7529e2934aa2cd8fa0b72ffd4b69a7127aa851a 19 | - check it displays 10000 wei instead of 0.0000???001 ether 20 | 21 | 0xca873e41e050ef6a5d741504766008859e91d1a57d1e758663efb1e06ad3d61e 22 | - failed top level revert -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------