Skip to content

Commit

Permalink
Add initial inline address labeling tool
Browse files Browse the repository at this point in the history
This tool uses localStorage to store address label changes and has support
for loading labels from external lists once a UI is created for it.
  • Loading branch information
sealer3 committed Apr 27, 2024
1 parent 4ce2e10 commit 8ec9e84
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 5 deletions.
116 changes: 116 additions & 0 deletions src/api/address-resolver/CustomLabelResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { JsonRpcApiProvider } from "ethers";
import { BasicAddressResolver } from "./address-resolver";

type AddressMap = Record<string, string | undefined>;

/*
A singleton class so addresses aren't fetched more than once
*/
export class CustomLabelFetcher {
private static instance: CustomLabelFetcher;
private fetchedLabels: Map<string, string> = new Map();
private localStorageLabels: Map<string, string> = 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<string | undefined> {
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<string | undefined> {
const labelFetcher = CustomLabelFetcher.getInstance();
const label = await labelFetcher.getItem(address);
return label;
}

trusted(resolvedAddress: string | undefined): boolean | undefined {
return true;
}
}
3 changes: 3 additions & 0 deletions src/api/address-resolver/hardcoded-addresses/1337.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"0x67b1d87101671b127f5f8714789C7192f7ad340e": "Erigon devnet address"
}
5 changes: 5 additions & 0 deletions src/api/address-resolver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -35,6 +38,7 @@ _mainnetResolver.addResolver(ercTokenResolver);
_mainnetResolver.addResolver(hardcodedResolver);

const _defaultResolver = new CompositeAddressResolver();
_defaultResolver.addResolver(customLabelResolver);
_defaultResolver.addResolver(ercTokenResolver);
_defaultResolver.addResolver(hardcodedResolver);

Expand Down Expand Up @@ -63,3 +67,4 @@ resolverRendererRegistry.set(uniswapV2Resolver, uniswapV2PairRenderer);
resolverRendererRegistry.set(uniswapV3Resolver, uniswapV3PairRenderer);
resolverRendererRegistry.set(ercTokenResolver, tokenRenderer);
resolverRendererRegistry.set(hardcodedResolver, plainStringRenderer);
resolverRendererRegistry.set(customLabelResolver, plainStringRenderer);
33 changes: 28 additions & 5 deletions src/execution/address/AddressSubtitle.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -36,6 +37,8 @@ const AddressSubtitle: FC<AddressSubtitleProps> = ({
resolvedNameTrusted = true;
}

const [editingAddressTag, setEditingAddressTag] = useState<boolean>(false);

return (
<StandardSubtitle>
<div className="flex items-baseline space-x-2">
Expand All @@ -55,11 +58,31 @@ const AddressSubtitle: FC<AddressSubtitleProps> = ({
{/* Only display faucets for testnets who actually have any */}
{faucets && faucets.length > 0 && <Faucet address={address} rounded />}
{config?.experimental && <AddressAttributes address={address} full />}
{resolvedName && resolvedNameTrusted && (
<div className="rounded-lg bg-gray-200 px-2 py-1 text-sm text-gray-500">
<FontAwesomeIcon icon={faTag} size="1x" /> {resolvedName}
{resolvedName && resolvedNameTrusted && !editingAddressTag && (
<div className="rounded-lg bg-gray-200 px-2 py-1 text-sm text-gray-500 text-nowrap">
<FontAwesomeIcon icon={faTag} size="1x" />
<span className="pl-1 text-nowrap">{resolvedName}</span>
</div>
)}
<div className="flex flex-no-wrap space-x-1">
{editingAddressTag && (
<EditableAddressTag
address={address}
defaultTag={resolvedName}
editedCallback={(address: string) => setEditingAddressTag(false)}
/>
)}
<button
className={`flex-no-wrap flex items-center justify-center space-x-1 self-center text-gray-500 focus:outline-none transition-shadows h-7 w-7 rounded-full bg-gray-200 text-xs transition-colors hover:bg-gray-500 hover:text-gray-200 hover:shadow`}
title={editingAddressTag ? "Cancel changes" : "Edit address label"}
onClick={() => setEditingAddressTag(!editingAddressTag)}
>
<FontAwesomeIcon
icon={editingAddressTag ? faTimes : faPencil}
size="1x"
/>
</button>
</div>
</div>
</StandardSubtitle>
);
Expand Down
76 changes: 76 additions & 0 deletions src/execution/address/EditableAddressTag.tsx
Original file line number Diff line number Diff line change
@@ -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<EditableAddressTagProps> = ({
address,
defaultTag,
editedCallback,
}) => {
const inputRef = React.createRef<HTMLInputElement>();
const formRef = React.createRef<HTMLFormElement>();

useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);

return (
<form
onSubmit={(event) => {
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}
>
<div className="rounded-lg bg-gray-200 px-2 py-1 text-sm text-gray-500 space-x-1">
<FontAwesomeIcon icon={faTag} size="1x" />
<input
type="text"
data-address={address}
placeholder={defaultTag ?? "Address"}
defaultValue={defaultTag}
ref={inputRef}
/>
</div>
<button
className={`flex-no-wrap flex items-center justify-center space-x-1 self-center text-gray-500 ${"transition-shadows h-7 w-7 rounded-full bg-gray-200 text-xs transition-colors hover:bg-gray-500 hover:text-gray-200 hover:shadow"}`}
title="Submit address label"
type="submit"
>
<FontAwesomeIcon icon={faCheck} size="1x" />
</button>
</form>
);
};

export default EditableAddressTag;

0 comments on commit 8ec9e84

Please sign in to comment.