diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts
index 69e8429..8e58b16 100644
--- a/ui/src/components/index.ts
+++ b/ui/src/components/index.ts
@@ -7,3 +7,4 @@ export * from './toast';
export * from './step';
export * from './nfa-card';
export * from './nfa-preview';
+export * from './resolved-address';
diff --git a/ui/src/components/resolved-address/index.ts b/ui/src/components/resolved-address/index.ts
new file mode 100644
index 0000000..c5b1f78
--- /dev/null
+++ b/ui/src/components/resolved-address/index.ts
@@ -0,0 +1 @@
+export * from './resolved-address';
diff --git a/ui/src/components/resolved-address/resolved-address.styles.ts b/ui/src/components/resolved-address/resolved-address.styles.ts
new file mode 100644
index 0000000..5b3fb23
--- /dev/null
+++ b/ui/src/components/resolved-address/resolved-address.styles.ts
@@ -0,0 +1,21 @@
+import { keyframes, styled } from '@/theme';
+
+const Loading = keyframes({
+ '0%': {
+ opacity: 1,
+ },
+ '50%': {
+ opacity: 0.5,
+ },
+ '100%': {
+ opacity: 1,
+ },
+});
+
+export const ResolvedAddressStyles = {
+ Container: styled('span', {
+ '&[data-loading="true"]': {
+ animation: `${Loading} 1s ease-in-out infinite`,
+ },
+ }),
+};
diff --git a/ui/src/components/resolved-address/resolved-address.tsx b/ui/src/components/resolved-address/resolved-address.tsx
new file mode 100644
index 0000000..58edc30
--- /dev/null
+++ b/ui/src/components/resolved-address/resolved-address.tsx
@@ -0,0 +1,31 @@
+import { useMemo } from 'react';
+
+import { useResolvedAddress } from '@/store';
+import { forwardStyledRef } from '@/theme';
+
+import { ResolvedAddressStyles as RAS } from './resolved-address.styles';
+
+export type ResolvedAddressProps = React.ComponentPropsWithRef<
+ typeof RAS.Container
+> & {
+ children: string;
+};
+
+export const ResolvedAddress = forwardStyledRef<
+ HTMLSpanElement,
+ ResolvedAddressProps
+>(({ children, ...props }, ref) => {
+ const [resolvedAddress, loading] = useResolvedAddress(children);
+
+ const text = useMemo(() => {
+ if (!resolvedAddress.endsWith('.eth'))
+ return `${resolvedAddress.slice(0, 6)}...${resolvedAddress.slice(-4)}`;
+ return resolvedAddress;
+ }, [resolvedAddress]);
+
+ return (
+
+ {text}
+
+ );
+});
diff --git a/ui/src/store/features/ens/async-thunk/index.ts b/ui/src/store/features/ens/async-thunk/index.ts
new file mode 100644
index 0000000..29a1344
--- /dev/null
+++ b/ui/src/store/features/ens/async-thunk/index.ts
@@ -0,0 +1 @@
+export * from './resolve-address';
diff --git a/ui/src/store/features/ens/async-thunk/resolve-address.ts b/ui/src/store/features/ens/async-thunk/resolve-address.ts
new file mode 100644
index 0000000..6178b9a
--- /dev/null
+++ b/ui/src/store/features/ens/async-thunk/resolve-address.ts
@@ -0,0 +1,37 @@
+import { createAsyncThunk } from '@reduxjs/toolkit';
+import { ethers } from 'ethers';
+
+import { env } from '@/constants';
+import { ENSActions, RootState } from '@/store';
+import { AppLog } from '@/utils';
+
+export const resolveAddress = createAsyncThunk(
+ 'ENS/fetchAddress',
+ async (address, { dispatch, getState }) => {
+ const { addressMap } = (getState() as RootState).ENS;
+ const stored = addressMap[address] || {};
+
+ if (stored.state === 'loading') return;
+
+ try {
+ dispatch(
+ ENSActions.setAddress({ key: address, value: { state: 'loading' } })
+ );
+
+ const provider = new ethers.providers.JsonRpcProvider(env.goerli.rpc);
+ const value = (await provider.lookupAddress(address)) || undefined;
+
+ dispatch(
+ ENSActions.setAddress({
+ key: address,
+ value: { state: 'success', value },
+ })
+ );
+ } catch (error) {
+ AppLog.error('Failed to resolve ENS name by address', error);
+ dispatch(
+ ENSActions.setAddress({ key: address, value: { state: 'loading' } })
+ );
+ }
+ }
+);
diff --git a/ui/src/store/features/ens/ens-slice.ts b/ui/src/store/features/ens/ens-slice.ts
new file mode 100644
index 0000000..4a9d501
--- /dev/null
+++ b/ui/src/store/features/ens/ens-slice.ts
@@ -0,0 +1,52 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+import { RootState } from '@/store';
+import { useAppSelector } from '@/store/hooks';
+
+import * as asyncThunk from './async-thunk';
+
+export namespace ENSState {
+ export type QueryState = undefined | 'loading' | 'failed' | 'success';
+
+ export type Address = {
+ state: QueryState;
+ value?: string;
+ };
+
+ export type AddressMap = Record;
+}
+
+export interface ENSState {
+ addressMap: ENSState.AddressMap;
+}
+
+const initialState: ENSState = {
+ addressMap: {},
+};
+
+export const ENSSlice = createSlice({
+ name: 'ENSSLice',
+ initialState,
+ reducers: {
+ setAddress: (
+ state,
+ action: PayloadAction<{
+ key: string;
+ value: ENSState.Address;
+ }>
+ ) => {
+ state.addressMap[action.payload.key] = action.payload.value;
+ },
+ },
+});
+
+export const ENSActions = {
+ ...ENSSlice.actions,
+ ...asyncThunk,
+};
+
+const selectENSState = (state: RootState): ENSState => state.ENS;
+
+export const useENSStore = (): ENSState => useAppSelector(selectENSState);
+
+export default ENSSlice.reducer;
diff --git a/ui/src/store/features/ens/hooks/index.ts b/ui/src/store/features/ens/hooks/index.ts
new file mode 100644
index 0000000..1920088
--- /dev/null
+++ b/ui/src/store/features/ens/hooks/index.ts
@@ -0,0 +1 @@
+export * from './use-resolved-address';
diff --git a/ui/src/store/features/ens/hooks/use-resolved-address.ts b/ui/src/store/features/ens/hooks/use-resolved-address.ts
new file mode 100644
index 0000000..edf2256
--- /dev/null
+++ b/ui/src/store/features/ens/hooks/use-resolved-address.ts
@@ -0,0 +1,21 @@
+import { useEffect, useMemo } from 'react';
+
+import { useAppDispatch } from '@/store/hooks';
+
+import { ENSActions, useENSStore } from '../ens-slice';
+
+export const useResolvedAddress = (address: string): [string, boolean] => {
+ const { addressMap } = useENSStore();
+ const dispatch = useAppDispatch();
+
+ useEffect(() => {
+ const stored = addressMap[address] || {};
+ if (typeof stored.state !== 'undefined') return;
+ dispatch(ENSActions.resolveAddress(address));
+ }, [address, dispatch, addressMap]);
+
+ return useMemo(() => {
+ const stored = addressMap[address] || {};
+ return [stored.value || address, addressMap[address]?.state === 'loading'];
+ }, [address, addressMap]);
+};
diff --git a/ui/src/store/features/ens/index.ts b/ui/src/store/features/ens/index.ts
new file mode 100644
index 0000000..27b3bab
--- /dev/null
+++ b/ui/src/store/features/ens/index.ts
@@ -0,0 +1,2 @@
+export * from './ens-slice';
+export * from './hooks';
diff --git a/ui/src/store/features/index.ts b/ui/src/store/features/index.ts
index 2e74368..bf09fad 100644
--- a/ui/src/store/features/index.ts
+++ b/ui/src/store/features/index.ts
@@ -1,3 +1,4 @@
export * from './fleek-erc721';
export * from './github';
export * from './toasts';
+export * from './ens';
diff --git a/ui/src/store/store.ts b/ui/src/store/store.ts
index 6144369..79511cc 100644
--- a/ui/src/store/store.ts
+++ b/ui/src/store/store.ts
@@ -1,5 +1,6 @@
import { configureStore } from '@reduxjs/toolkit';
+import ENSReducer from './features/ens/ens-slice';
import fleekERC721Reducer from './features/fleek-erc721/fleek-erc721-slice';
import githubReducer from './features/github/github-slice';
import toastsReducer from './features/toasts/toasts-slice';
@@ -9,6 +10,7 @@ export const store = configureStore({
fleekERC721: fleekERC721Reducer,
github: githubReducer,
toasts: toastsReducer,
+ ENS: ENSReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
diff --git a/ui/src/views/components-test/components-test.tsx b/ui/src/views/components-test/components-test.tsx
index bce37f7..ec9655f 100644
--- a/ui/src/views/components-test/components-test.tsx
+++ b/ui/src/views/components-test/components-test.tsx
@@ -1,4 +1,4 @@
-import { Flex } from '@/components';
+import { Flex, ResolvedAddress } from '@/components';
import { ColorPickerTest } from './color-picker';
import { ComboboxTest } from './combobox-test';
@@ -7,6 +7,9 @@ import { ToastTest } from './toast-test';
export const ComponentsTest: React.FC = () => {
return (
+
+ {'0x7ed735b7095c05d78df169f991f2b7f1a1f1a049'}
+