Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ NEXT_PUBLIC_SUBGRAPH_ID=FE63YgkzcpVocxdCEyEYbvjYqEf2kb1A6daMYRxmejYC
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
NEXT_PUBLIC_METRICS_SERVER_URL=https://leaderboard-serverless.vercel.app
NEXT_PUBLIC_AI_METRICS_SERVER_URL=https://leaderboard-api.livepeer.cloud
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# Optional dev overrides (e.g. Graph Studio sandbox; leave empty in prod)
NEXT_PUBLIC_SUBGRAPH_ENDPOINT=
NEXT_PUBLIC_SUBGRAPH_ENDPOINT=
6 changes: 6 additions & 0 deletions components/Profile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ExplorerTooltip } from "@components/ExplorerTooltip";
import ShowMoreRichText from "@components/ShowMoreRichText";
import { EnsIdentity } from "@lib/api/types/get-ens";
import dayjs from "@lib/dayjs";
import {
Box,
Button,
Expand Down Expand Up @@ -271,6 +272,11 @@ const Index = ({
</Box>
)}
</Flex>
{identity?.computedAt && identity?.name && (
<Text variant="neutral" size="1" css={{ marginTop: "$2" }}>
ENS last verified {dayjs(identity.computedAt).fromNow()}
</Text>
)}
<Flex align="center" css={{ flexWrap: "wrap" }}>
{identity?.url && (
<A
Expand Down
24 changes: 21 additions & 3 deletions hooks/useSwr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
VotingPower,
} from "@lib/api/types/get-treasury-proposal";
import { formatAddress } from "@utils/web3";
import useSWR from "swr";
import useSWR, { useSWRConfig } from "swr";
import { Address } from "viem";

export const useRegionsData = (): Regions => {
Expand All @@ -36,16 +36,34 @@ export const useRegionsData = (): Regions => {
};

export const useEnsData = (address: string | undefined | null): EnsIdentity => {
const { data, isValidating, error } = useSWR<EnsIdentity>(
address ? `/ens-data/${address.toLowerCase()}` : null
const normalizedAddress = address?.toLowerCase();

const { cache } = useSWRConfig();
const bulkEntry = cache.get("/ens-data") as
| { data?: EnsIdentity[] }
| undefined;
const matched = bulkEntry?.data?.find(
(e) => e.id.toLowerCase() === normalizedAddress
);

const soloKey =
normalizedAddress && !matched ? `/ens-data/${normalizedAddress}` : null;

const { data, isValidating, error } = useSWR<EnsIdentity>(soloKey);

const fallbackIdentity: EnsIdentity = {
id: address ?? "",
idShort: formatAddress(address),
name: null,
};

if (matched) {
return {
...matched,
isLoading: false,
};
}

return {
...(data ?? fallbackIdentity),
isLoading: Boolean(address && !data && isValidating && !error),
Expand Down
2 changes: 1 addition & 1 deletion lib/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const cacheControlValues = {
export const cacheControlValues = {
revalidate: {
maxAge: 0,
swr: 10,
Expand Down
179 changes: 169 additions & 10 deletions lib/api/ens.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
import { cacheControlValues } from "@lib/api/api";
import { l1PublicClient } from "@lib/chains";
import { Redis } from "@upstash/redis";
import { formatAddress } from "@utils/web3";
import { parseArweaveTxId, parseCid } from "livepeer/utils";
import sanitizeHtml from "sanitize-html";
import { isAddress } from "viem";
import { normalize } from "viem/ens";

import { EnsIdentity } from "./types/get-ens";

export const ENS_BLACKLISTED_ADDRESSES = [
"0xcb69ffc06d3c218472c50ee25f5a1d3ca9650c44",
].map((a) => a.toLowerCase());

export const ENS_CACHE_TTL = "week";

const redis =
typeof window === "undefined" &&
process.env.UPSTASH_REDIS_REST_URL &&
process.env.UPSTASH_REDIS_REST_TOKEN
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN,
})
: null;

if (!redis && typeof window === "undefined") {
console.warn(
"ENS cache: UPSTASH_REDIS_REST_URL/TOKEN not set, running without caching (every request will hit L1 directly)."
);
}

const ENS_CACHE_TTL_SECONDS = cacheControlValues[ENS_CACHE_TTL].maxAge;
const ENS_LOCK_TTL_SECONDS = 20;

const sanitizeOptions: sanitizeHtml.IOptions = {
allowedTags: [
"b",
Expand Down Expand Up @@ -54,13 +82,144 @@ const sanitizeOptions: sanitizeHtml.IOptions = {
enforceHtmlBoundary: true,
};

export class LockBusyError extends Error {
constructor(message: string) {
super(message);
this.name = "LockBusyError";
}
}

export const getAvatarUrlCached = async (
address: string,
name: string
): Promise<string | null> => {
if (!redis) {
return resolveAvatarUrl(name);
}

const key = `avatar-url:${address.toLowerCase()}`;

try {
const cached = await redis.get<string>(key);
if (cached) {
return cached;
}
} catch (err) {
console.error("Avatar URL cache read failed:", err);
return resolveAvatarUrl(name);
}

const imageUrl = await resolveAvatarUrl(name);

if (!imageUrl) {
return null;
}

try {
await redis.set(key, imageUrl, { ex: ENS_CACHE_TTL_SECONDS });
} catch (err) {
console.error("Avatar URL cache write failed:", err);
return imageUrl;
}
return imageUrl;
};

const resolveAvatarUrl = async (name: string): Promise<string | null> => {
const avatar = await l1PublicClient.getEnsAvatar({ name: normalize(name) });

const cid = parseCid(avatar);
const arweaveId = parseArweaveTxId(avatar);

const hasAvatarRecord = Boolean(avatar);

const result = cid?.id
? `https://dweb.link/ipfs/${cid.id}`
: arweaveId?.id
? arweaveId?.url
: avatar?.startsWith("https://")
? avatar
: hasAvatarRecord
? `https://metadata.ens.domains/mainnet/avatar/${name}`
: null;

return result;
};

export const getEnsForAddressCached = async (
address: string | null | undefined
): Promise<EnsIdentity> => {
const key = (address ?? "").toLowerCase();

if (!redis) {
const ens = await getEnsForAddress(address);
return { ...ens, computedAt: Date.now() };
}

try {
const cached = await redis.get<EnsIdentity>(key);

if (cached) {
return cached;
}
} catch (err) {
console.error("ENS cache read failed:", err);
const ens = await getEnsForAddress(address);
const stampedEns = { ...ens, computedAt: Date.now() };
return stampedEns;
}

const lockKey = `lock:${key}`;
let lockAcquired = true;

try {
const lockResult = await redis.set(lockKey, "1", {
nx: true,
ex: ENS_LOCK_TTL_SECONDS,
});
lockAcquired = lockResult !== null;
} catch (err) {
console.error("ENS lock acquisition failed, proceeding unlocked:", err);
lockAcquired = true;
}

if (!lockAcquired) {
throw new LockBusyError(
"Another request is already resolving this address"
);
}

const ens = await getEnsForAddress(address);
const stampedEns = { ...ens, computedAt: Date.now() };

try {
await redis.set(key, stampedEns, { ex: ENS_CACHE_TTL_SECONDS });
} catch (err) {
console.error("ENS cache write failed:", err);
return stampedEns;
} finally {
try {
await redis.del(lockKey);
} catch (err) {
console.error("ENS lock release failed:", err);
}
}
return stampedEns;
};

export const getEnsForAddress = async (address: string | null | undefined) => {
const idShort = address?.replace(address?.slice(6, 38), "…");
if (!address) {
return {
id: "",
idShort: "",
name: null,
} as EnsIdentity;
}

const name =
address && isAddress(address)
? await l1PublicClient.getEnsName({ address })
: null;
const idShort = address.replace(address.slice(6, 38), "…");

const name = isAddress(address)
? await l1PublicClient.getEnsName({ address })
: null;

if (name) {
const normalizedName = normalize(name);
Expand All @@ -73,24 +232,24 @@ export const getEnsForAddress = async (address: string | null | undefined) => {
]);

const ens: EnsIdentity = {
id: address ?? "",
idShort: idShort ?? "",
id: address,
idShort: idShort,
name: name ?? null,
description: sanitizeHtml(nl2br(description), sanitizeOptions),
url,
twitter,
github,
avatar: avatar
? `/api/ens-data/image/${encodeURIComponent(normalizedName)}`
? `/api/ens-data/image/${encodeURIComponent(address)}`
: null,
};

return ens;
}

const ens: EnsIdentity = {
id: address ?? "",
idShort: idShort ?? "",
id: address,
idShort: idShort,
name: null,
};

Expand Down
6 changes: 6 additions & 0 deletions lib/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,9 @@ export const methodNotAllowed = (
`Method ${method} Not Allowed`
);
};

export const serviceBusy = (
res: NextApiResponse,
message: string,
details?: string
) => apiError(res, 503, "SERVICE_BUSY", message, details);
3 changes: 2 additions & 1 deletion lib/api/types/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export type ErrorCode =
| "VALIDATION_ERROR"
| "NOT_FOUND"
| "EXTERNAL_API_ERROR"
| "METHOD_NOT_ALLOWED";
| "METHOD_NOT_ALLOWED"
| "SERVICE_BUSY";
1 change: 1 addition & 0 deletions lib/api/types/get-ens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export type EnsIdentity = {
github?: string | null;
description?: string | null;
isLoading?: boolean;
computedAt?: number;
};
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@
"eslint": "^9.39.1",
"eslint-config-next": "16.0.1",
"eslint-config-prettier": "^10.1.8",
"prettier": "^2.8.8",
"eslint-plugin-simple-import-sort": "^12.1.1",
"husky": "^9.1.7",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"prettier": "^2.8.8",
"ts-node": "^10.9.2",
"typescript": "5.9.2"
},
Expand All @@ -54,6 +54,7 @@
"@reach/tabs": "^0.17.0",
"@stitches/react": "1.2.5",
"@tanstack/react-query": "^5.90.5",
"@upstash/redis": "^1.38.2",
"apollo-fetch": "^0.7.0",
"change-case": "^4.1.2",
"copy-to-clipboard": "^3.3.3",
Expand Down
Loading