diff --git a/src/api/address-resolver/CustomLabelResolver.ts b/src/api/address-resolver/CustomLabelResolver.ts new file mode 100644 index 000000000..64fd9e942 --- /dev/null +++ b/src/api/address-resolver/CustomLabelResolver.ts @@ -0,0 +1,116 @@ +import { JsonRpcApiProvider } from "ethers"; +import { BasicAddressResolver } from "./address-resolver"; + +type AddressMap = Record; + +/* + A singleton class so addresses aren't fetched more than once +*/ +export class CustomLabelFetcher { + private static instance: CustomLabelFetcher; + private fetchedLabels: Map = new Map(); + private localStorageLabels: Map = new Map(); + private fetched: boolean = false; + // List of URLs from which address-label mappings are fetched + // TODO: Potentially populate from the config file + private defaultLabelSources: string[] = []; + + private constructor() {} + + public static getInstance(): CustomLabelFetcher { + if (!CustomLabelFetcher.instance) { + CustomLabelFetcher.instance = new CustomLabelFetcher(); + } + return CustomLabelFetcher.instance; + } + + public async fetchLabels(localOnly: boolean = false) { + // Fetch labels from label sources + if (!localOnly) { + const _this = this; + async function fetchLabels(url: string) { + try { + const response = await fetch(url); + const data = (await response.json()) as { + [key: string]: string; + }; + Object.entries(data).forEach(([key, value]: [string, string]) => + _this.fetchedLabels.set(key, value), + ); + } catch (e) { + console.error(`Error loading address labels from ${url}:`, e); + } + } + + await Promise.all( + this.defaultLabelSources.map((url: string) => fetchLabels(url)), + ); + } + + // Load labels from localStorage + this.localStorageLabels.clear(); + const localStorageAddrsString = localStorage.getItem("customAddressLabels"); + if (typeof localStorageAddrsString === "string") { + try { + const localLabels = JSON.parse(localStorageAddrsString) as [ + string, + string, + ][]; + for (let addressTag of localLabels) { + this.localStorageLabels.set(addressTag[0], addressTag[1]); + } + } catch (e) { + console.error(e); + } + } + + if (!localOnly) { + this.fetched = true; + } + } + + public async updateLabels(newItem: { [address: string]: string }) { + // Update our view of the localStorage addresses + await this.fetchLabels(true); + Object.entries(newItem).forEach(([key, value]) => { + if (value === "") { + this.localStorageLabels.delete(key); + } else { + this.localStorageLabels.set(key, value); + } + }); + localStorage.setItem( + "customAddressLabels", + JSON.stringify([...this.localStorageLabels]), + ); + } + + public async getItem(key: string): Promise { + if (!this.fetched) { + await this.fetchLabels(); + } + // localStorage labels have priority + if (this.localStorageLabels.has(key)) { + return this.localStorageLabels.get(key); + } else if (this.fetchedLabels.has(key)) { + return this.fetchedLabels.get(key); + } else { + return undefined; + } + } +} + +export class CustomLabelResolver extends BasicAddressResolver { + async resolveAddress( + provider: JsonRpcApiProvider, + address: string, + ): Promise { + const labelFetcher = CustomLabelFetcher.getInstance(); + const label = await labelFetcher.getItem(address); + return label; + } + + trusted(resolvedAddress: string | undefined): boolean | undefined { + return true; + } +} diff --git a/src/api/address-resolver/hardcoded-addresses/1337.json b/src/api/address-resolver/hardcoded-addresses/1337.json new file mode 100644 index 000000000..f766815e3 --- /dev/null +++ b/src/api/address-resolver/hardcoded-addresses/1337.json @@ -0,0 +1,3 @@ +{ + "0x67b1d87101671b127f5f8714789C7192f7ad340e": "Erigon devnet address" +} diff --git a/src/api/address-resolver/index.ts b/src/api/address-resolver/index.ts index 8715d0db1..a7953e0c5 100644 --- a/src/api/address-resolver/index.ts +++ b/src/api/address-resolver/index.ts @@ -8,6 +8,7 @@ import { CompositeAddressResolver, SelectedResolvedName, } from "./CompositeAddressResolver"; +import { CustomLabelResolver } from "./CustomLabelResolver"; import { ENSAddressResolver } from "./ENSAddressResolver"; import { ERCTokenResolver } from "./ERCTokenResolver"; import { HardcodedAddressResolver } from "./HardcodedAddressResolver"; @@ -25,8 +26,10 @@ const uniswapV2Resolver = new UniswapV2Resolver(); const uniswapV3Resolver = new UniswapV3Resolver(); const ercTokenResolver = new ERCTokenResolver(); const hardcodedResolver = new HardcodedAddressResolver(); +export const customLabelResolver = new CustomLabelResolver(); const _mainnetResolver = new CompositeAddressResolver(); +_mainnetResolver.addResolver(customLabelResolver); _mainnetResolver.addResolver(ensResolver); _mainnetResolver.addResolver(uniswapV3Resolver); _mainnetResolver.addResolver(uniswapV2Resolver); @@ -35,6 +38,7 @@ _mainnetResolver.addResolver(ercTokenResolver); _mainnetResolver.addResolver(hardcodedResolver); const _defaultResolver = new CompositeAddressResolver(); +_defaultResolver.addResolver(customLabelResolver); _defaultResolver.addResolver(ercTokenResolver); _defaultResolver.addResolver(hardcodedResolver); @@ -63,3 +67,4 @@ resolverRendererRegistry.set(uniswapV2Resolver, uniswapV2PairRenderer); resolverRendererRegistry.set(uniswapV3Resolver, uniswapV3PairRenderer); resolverRendererRegistry.set(ercTokenResolver, tokenRenderer); resolverRendererRegistry.set(hardcodedResolver, plainStringRenderer); +resolverRendererRegistry.set(customLabelResolver, plainStringRenderer); diff --git a/src/execution/address/AddressSubtitle.tsx b/src/execution/address/AddressSubtitle.tsx index ef032317a..91c6e6269 100644 --- a/src/execution/address/AddressSubtitle.tsx +++ b/src/execution/address/AddressSubtitle.tsx @@ -1,6 +1,6 @@ -import { faTag } from "@fortawesome/free-solid-svg-icons"; +import { faPencil, faTag, faTimes } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { FC, useContext } from "react"; +import { FC, useContext, useState } from "react"; import Blockies from "react-blockies"; import Copy from "../../components/Copy"; import Faucet from "../../components/Faucet"; @@ -10,6 +10,7 @@ import { useResolvedAddress } from "../../useResolvedAddresses"; import { RuntimeContext } from "../../useRuntime"; import { AddressAwareComponentProps } from "../types"; import AddressAttributes from "./AddressAttributes"; +import EditableAddressTag from "./EditableAddressTag"; type AddressSubtitleProps = AddressAwareComponentProps & { isENS: boolean | undefined; @@ -36,6 +37,8 @@ const AddressSubtitle: FC = ({ resolvedNameTrusted = true; } + const [editingAddressTag, setEditingAddressTag] = useState(false); + return (
@@ -55,11 +58,31 @@ const AddressSubtitle: FC = ({ {/* Only display faucets for testnets who actually have any */} {faucets && faucets.length > 0 && } {config?.experimental && } - {resolvedName && resolvedNameTrusted && ( -
- {resolvedName} + {resolvedName && resolvedNameTrusted && !editingAddressTag && ( +
+ + {resolvedName}
)} +
+ {editingAddressTag && ( + setEditingAddressTag(false)} + /> + )} + +
); diff --git a/src/execution/address/EditableAddressTag.tsx b/src/execution/address/EditableAddressTag.tsx new file mode 100644 index 000000000..919af571d --- /dev/null +++ b/src/execution/address/EditableAddressTag.tsx @@ -0,0 +1,76 @@ +import { faCheck, faTag } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import React, { FC, useEffect } from "react"; +import { mutate } from "swr"; +import { customLabelResolver } from "../../api/address-resolver"; +import { CustomLabelFetcher } from "../../api/address-resolver/CustomLabelResolver"; +import { AddressAwareComponentProps } from "../types"; + +type EditableAddressTagProps = AddressAwareComponentProps & { + defaultTag: string | undefined; + editedCallback?: (newLabel: string) => void; +}; + +async function setAddressLabel(address: string, label: string | null) { + if (label === null) { + return; + } + const trimmedLabel = label.trim(); + await CustomLabelFetcher.getInstance().updateLabels({ + [address]: trimmedLabel, + }); + // Update the SWR entry so that all components using this label are invalidated + mutate(address, [customLabelResolver, trimmedLabel]); +} + +const EditableAddressTag: FC = ({ + address, + defaultTag, + editedCallback, +}) => { + const inputRef = React.createRef(); + const formRef = React.createRef(); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + return ( +
{ + event.preventDefault(); + setAddressLabel( + address, + inputRef.current ? inputRef.current.value : null, + ); + if (editedCallback && inputRef.current) { + editedCallback(inputRef.current.value); + } + }} + className="flex space-x-1 text-sm" + ref={formRef} + > +
+ + +
+ +
+ ); +}; + +export default EditableAddressTag;