Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
NEXT_PUBLIC_UPSTASH_REDIS_REST_URL=
NEXT_PUBLIC_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
147 changes: 137 additions & 10 deletions lib/api/ens.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
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 = new Redis({
url: process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_URL,
token: process.env.NEXT_PUBLIC_UPSTASH_REDIS_REST_TOKEN,
});

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 +71,123 @@ 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> => {
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);

return cid?.id
? `https://dweb.link/ipfs/${cid.id}`
: arweaveId?.id
? arweaveId?.url
: avatar?.startsWith("https://")
? avatar
: null;
};
Comment on lines +127 to +146

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

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;
}
return stampedEns;
Comment on lines +191 to +206
};

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 +200,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
23 changes: 16 additions & 7 deletions pages/api/ens-data/[address].tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { getCacheControlHeader } from "@lib/api";
import { getEnsForAddress } from "@lib/api/ens";
import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors";
import {
ENS_BLACKLISTED_ADDRESSES,
ENS_CACHE_TTL,
getEnsForAddressCached,
LockBusyError,
} from "@lib/api/ens";
import {
badRequest,
internalError,
methodNotAllowed,
serviceBusy,
} from "@lib/api/errors";
import { EnsIdentity } from "@lib/api/types/get-ens";
import { NextApiRequest, NextApiResponse } from "next";
import { Address, isAddress } from "viem";

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

const handler = async (
req: NextApiRequest,
Expand All @@ -19,15 +27,15 @@ const handler = async (
if (method === "GET") {
const { address } = req.query;

res.setHeader("Cache-Control", getCacheControlHeader("week"));
res.setHeader("Cache-Control", getCacheControlHeader(ENS_CACHE_TTL));

if (
!!address &&
!Array.isArray(address) &&
isAddress(address) &&
!blacklist.includes(address.toLowerCase())
) {
const ens = await getEnsForAddress(address as Address);
const ens = await getEnsForAddressCached(address as Address);

return res.status(200).json(ens);
} else {
Expand All @@ -37,6 +45,7 @@ const handler = async (

return methodNotAllowed(res, method ?? "unknown", ["GET"]);
} catch (err) {
if (err instanceof LockBusyError) return serviceBusy(res, err.message);
return internalError(res, err);
}
};
Expand Down
Loading