From a2deb9cfd9632bfcb0402c9bd36c37826216349a Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 15 Dec 2025 17:33:11 +0200 Subject: [PATCH 01/15] Alchemy failover backend proxy Signed-off-by: prxt6529 --- __tests__/hooks/useAlchemyNftQueries.test.ts | 189 ++++++++++++++++++ .../mint/NextGenMintBurnWidget.tsx | 21 +- hooks/useAlchemyNftQueries.ts | 106 +++++++--- 3 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 __tests__/hooks/useAlchemyNftQueries.test.ts diff --git a/__tests__/hooks/useAlchemyNftQueries.test.ts b/__tests__/hooks/useAlchemyNftQueries.test.ts new file mode 100644 index 0000000000..4737a1a71c --- /dev/null +++ b/__tests__/hooks/useAlchemyNftQueries.test.ts @@ -0,0 +1,189 @@ +import { fetchOwnerNfts } from "@/hooks/useAlchemyNftQueries"; + +const mockPublicEnv = { + API_ENDPOINT: "https://api.example.com", + BASE_ENDPOINT: "https://example.com", + ALLOWLIST_API_ENDPOINT: "https://allowlist.example.com", +}; + +jest.mock("@/config/env", () => ({ + publicEnv: { + API_ENDPOINT: "https://api.example.com", + BASE_ENDPOINT: "https://example.com", + ALLOWLIST_API_ENDPOINT: "https://allowlist.example.com", + }, +})); + +describe("useAlchemyNftQueries", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe("fetchOwnerNfts", () => { + const mockNfts = [ + { + tokenId: "1", + tokenType: "ERC721", + name: "Test NFT", + tokenUri: "https://example.com/1", + image: null, + }, + ]; + + it("should return data from primary endpoint when successful", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockNfts), + }); + + const result = await fetchOwnerNfts(1, "0x123", "0xowner"); + + expect(result).toEqual(mockNfts); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + "/api/alchemy/owner-nfts?chainId=1&contract=0x123&owner=0xowner", + { signal: undefined } + ); + }); + + it("should fallback to backend proxy when primary endpoint fails with non-ok response", async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 400, + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockNfts), + }); + + const result = await fetchOwnerNfts(1, "0x123", "0xowner"); + + expect(result).toEqual(mockNfts); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + "/api/alchemy/owner-nfts?chainId=1&contract=0x123&owner=0xowner", + { signal: undefined } + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + `${mockPublicEnv.API_ENDPOINT}/alchemy-proxy/owner-nfts?chainId=1&contract=0x123&owner=0xowner`, + { signal: undefined } + ); + }); + + it("should fallback to backend proxy when primary endpoint throws network error", async () => { + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockNfts), + }); + + const result = await fetchOwnerNfts(1, "0x123", "0xowner"); + + expect(result).toEqual(mockNfts); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it("should throw error when both primary and fallback fail", async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow( + "Request failed with status 500" + ); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it("should pass abort signal to fetch calls", async () => { + const controller = new AbortController(); + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockNfts), + }); + + await fetchOwnerNfts(1, "0x123", "0xowner", controller.signal); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(String), + { signal: controller.signal } + ); + }); + + it("should handle different chain IDs correctly", async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockNfts), + }); + + await fetchOwnerNfts(11155111, "0x123", "0xowner"); + + expect(global.fetch).toHaveBeenCalledWith( + "/api/alchemy/owner-nfts?chainId=11155111&contract=0x123&owner=0xowner", + { signal: undefined } + ); + }); + }); + + describe("fetchJsonWithFailover (via fetchOwnerNfts)", () => { + it("should fallback when primary returns ALCHEMY_API_KEY error", async () => { + const mockNfts = [{ tokenId: "1", tokenType: "ERC721", name: null, tokenUri: null, image: null }]; + + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 400, + json: () => Promise.resolve({ error: "ALCHEMY_API_KEY is required and must be a non-empty string" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockNfts), + }); + + const result = await fetchOwnerNfts(1, "0x123", "0xowner"); + + expect(result).toEqual(mockNfts); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it("should fallback when primary returns 500 server error", async () => { + const mockNfts = [{ tokenId: "1", tokenType: "ERC721", name: null, tokenUri: null, image: null }]; + + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockNfts), + }); + + const result = await fetchOwnerNfts(1, "0x123", "0xowner"); + + expect(result).toEqual(mockNfts); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + }); +}); + diff --git a/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx b/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx index 9754f6db5b..8b0a2d5736 100644 --- a/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx +++ b/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx @@ -12,6 +12,7 @@ import { areEqualAddresses, getNetworkName, } from "@/helpers/Helpers"; +import { fetchOwnerNfts } from "@/hooks/useAlchemyNftQueries"; import { fetchUrl } from "@/services/6529api"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import NextGenContractWriteStatus from "@/components/nextGen/NextGenContractWriteStatus"; @@ -113,21 +114,13 @@ export default function NextGenMintBurnWidget(props: Readonly) { return; } const controller = new AbortController(); - const searchParams = new URLSearchParams({ - chainId: String(NEXTGEN_CHAIN_ID), - contract: NEXTGEN_CORE[NEXTGEN_CHAIN_ID], - owner: burnAddress, - }); - fetch(`/api/alchemy/owner-nfts?${searchParams.toString()}`, { - signal: controller.signal, - }) - .then(async (response) => { - if (!response.ok) { - throw new Error("Failed to fetch NFTs for owner"); - } - return (await response.json()) as any[]; - }) + fetchOwnerNfts( + NEXTGEN_CHAIN_ID, + NEXTGEN_CORE[NEXTGEN_CHAIN_ID], + burnAddress, + controller.signal + ) .then((r) => { setTokensOwnedForBurnAddressLoaded(true); const filteredTokens = filterTokensOwnedForBurnAddress(r); diff --git a/hooks/useAlchemyNftQueries.ts b/hooks/useAlchemyNftQueries.ts index 1cd4cc98d4..ec5cff543d 100644 --- a/hooks/useAlchemyNftQueries.ts +++ b/hooks/useAlchemyNftQueries.ts @@ -1,13 +1,14 @@ "use client"; -import { useEffect, useMemo } from "react"; import { keepPreviousData, useQuery, useQueryClient, } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { publicEnv } from "@/config/env"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import type { SearchContractsResult, @@ -37,7 +38,14 @@ type SerializedTokenMetadata = Omit & { tokenId: string; }; -async function fetchJson(input: RequestInfo, init?: RequestInit): Promise { +function getBackendAlchemyProxyUrl(path: string): string { + return `${publicEnv.API_ENDPOINT}/alchemy-proxy${path}`; +} + +async function fetchJson( + input: RequestInfo, + init?: RequestInit +): Promise { const response = await fetch(input, init); if (!response.ok) { throw new Error(`Request failed with status ${response.status}`); @@ -45,6 +53,22 @@ async function fetchJson(input: RequestInfo, init?: RequestInit): Promise return (await response.json()) as T; } +async function fetchJsonWithFailover( + primaryUrl: string, + backendPath: string, + init?: RequestInit +): Promise { + try { + return await fetchJson(primaryUrl, init); + } catch { + const backendUrl = getBackendAlchemyProxyUrl(backendPath); + console.warn( + `Failed to fetch from primary endpoint (${primaryUrl}), falling back to proxy endpoint: (${backendUrl})` + ); + return fetchJson(backendUrl, init); + } +} + async function fetchCollectionsFromApi( params: UseCollectionSearchParams & { readonly signal?: AbortSignal } ): Promise { @@ -53,8 +77,10 @@ async function fetchCollectionsFromApi( search.set("query", query); search.set("chain", chain); search.set("hideSpam", hideSpam ? "1" : "0"); - return fetchJson( - `/api/alchemy/collections?${search.toString()}`, + const queryString = search.toString(); + return fetchJsonWithFailover( + `/api/alchemy/collections?${queryString}`, + `/collections?${queryString}`, { signal } ); } @@ -69,8 +95,10 @@ async function fetchContractOverviewFromApi( const search = new URLSearchParams(); search.set("address", address); search.set("chain", chain); - return fetchJson( - `/api/alchemy/contract?${search.toString()}`, + const queryString = search.toString(); + return fetchJsonWithFailover( + `/api/alchemy/contract?${queryString}`, + `/contract?${queryString}`, { signal } ); } @@ -78,23 +106,23 @@ async function fetchContractOverviewFromApi( async function fetchTokenMetadataFromApi( params: TokenMetadataParams ): Promise { - const response = await fetch("/api/alchemy/token-metadata", { + const body = JSON.stringify({ + address: params.address, + tokenIds: params.tokenIds, + tokens: params.tokens, + chain: params.chain ?? "ethereum", + }); + const init: RequestInit = { method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - address: params.address, - tokenIds: params.tokenIds, - tokens: params.tokens, - chain: params.chain ?? "ethereum", - }), + headers: { "Content-Type": "application/json" }, + body, signal: params.signal, - }); - if (!response.ok) { - throw new Error(`Request failed with status ${response.status}`); - } - const payload = (await response.json()) as SerializedTokenMetadata[]; + }; + const payload = await fetchJsonWithFailover( + "/api/alchemy/token-metadata", + "/token-metadata", + init + ); return payload.map((entry) => ({ ...entry, tokenId: BigInt(entry.tokenId), @@ -151,7 +179,9 @@ function getTokenCacheKey(params: TokenMetadataParams): string { } return 0; }); - return `${params.chain ?? "ethereum"}:${address.toLowerCase()}:${ids.join("|")}`; + return `${params.chain ?? "ethereum"}:${address.toLowerCase()}:${ids.join( + "|" + )}`; } type UseCollectionSearchParams = { @@ -191,11 +221,7 @@ export function useCollectionSearch({ staleTime: SUGGESTION_TTL, gcTime: SUGGESTION_TTL, queryFn: async ({ signal }) => { - const cacheKey = getSuggestionCacheKey( - debouncedQuery, - chain, - hideSpam - ); + const cacheKey = getSuggestionCacheKey(debouncedQuery, chain, hideSpam); const now = Date.now(); gcExpired(suggestionCache, now); const cached = suggestionCache.get(cacheKey); @@ -361,3 +387,29 @@ export function primeContractCache( expires: Date.now() + CONTRACT_TTL, }); } + +export async function fetchOwnerNfts( + chainId: number, + contract: string, + owner: string, + signal?: AbortSignal +): Promise< + { + tokenId: string; + tokenType: string; + name: string | null; + tokenUri: string | null; + image: unknown; + }[] +> { + const search = new URLSearchParams(); + search.set("chainId", String(chainId)); + search.set("contract", contract); + search.set("owner", owner); + const queryString = search.toString(); + return fetchJsonWithFailover( + `/api/alchemy/owner-nfts?${queryString}`, + `/owner-nfts?${queryString}`, + { signal } + ); +} From 4d920dbfefb50f5e6fac47bbd2abbe8d7193fb53 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 15 Dec 2025 17:42:46 +0200 Subject: [PATCH 02/15] WIP Signed-off-by: prxt6529 --- __tests__/hooks/useAlchemyNftQueries.test.ts | 58 ++++++++++++++++---- hooks/useAlchemyNftQueries.ts | 18 ++++-- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/__tests__/hooks/useAlchemyNftQueries.test.ts b/__tests__/hooks/useAlchemyNftQueries.test.ts index 4737a1a71c..e779f499e1 100644 --- a/__tests__/hooks/useAlchemyNftQueries.test.ts +++ b/__tests__/hooks/useAlchemyNftQueries.test.ts @@ -122,10 +122,9 @@ describe("useAlchemyNftQueries", () => { await fetchOwnerNfts(1, "0x123", "0xowner", controller.signal); - expect(global.fetch).toHaveBeenCalledWith( - expect.any(String), - { signal: controller.signal } - ); + expect(global.fetch).toHaveBeenCalledWith(expect.any(String), { + signal: controller.signal, + }); }); it("should handle different chain IDs correctly", async () => { @@ -141,18 +140,50 @@ describe("useAlchemyNftQueries", () => { { signal: undefined } ); }); + + it("should NOT fallback when request is aborted via AbortController", async () => { + const abortError = new DOMException( + "The operation was aborted.", + "AbortError" + ); + global.fetch = jest.fn().mockRejectedValueOnce(abortError); + + await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("should NOT fallback when request throws Error with name AbortError", async () => { + const abortError = new Error("The operation was aborted."); + abortError.name = "AbortError"; + global.fetch = jest.fn().mockRejectedValueOnce(abortError); + + await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow(); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); }); describe("fetchJsonWithFailover (via fetchOwnerNfts)", () => { it("should fallback when primary returns ALCHEMY_API_KEY error", async () => { - const mockNfts = [{ tokenId: "1", tokenType: "ERC721", name: null, tokenUri: null, image: null }]; - + const mockNfts = [ + { + tokenId: "1", + tokenType: "ERC721", + name: null, + tokenUri: null, + image: null, + }, + ]; + global.fetch = jest .fn() .mockResolvedValueOnce({ ok: false, status: 400, - json: () => Promise.resolve({ error: "ALCHEMY_API_KEY is required and must be a non-empty string" }), + json: () => + Promise.resolve({ + error: + "ALCHEMY_API_KEY is required and must be a non-empty string", + }), }) .mockResolvedValueOnce({ ok: true, @@ -166,8 +197,16 @@ describe("useAlchemyNftQueries", () => { }); it("should fallback when primary returns 500 server error", async () => { - const mockNfts = [{ tokenId: "1", tokenType: "ERC721", name: null, tokenUri: null, image: null }]; - + const mockNfts = [ + { + tokenId: "1", + tokenType: "ERC721", + name: null, + tokenUri: null, + image: null, + }, + ]; + global.fetch = jest .fn() .mockResolvedValueOnce({ @@ -186,4 +225,3 @@ describe("useAlchemyNftQueries", () => { }); }); }); - diff --git a/hooks/useAlchemyNftQueries.ts b/hooks/useAlchemyNftQueries.ts index ec5cff543d..de062f84a0 100644 --- a/hooks/useAlchemyNftQueries.ts +++ b/hooks/useAlchemyNftQueries.ts @@ -53,6 +53,16 @@ async function fetchJson( return (await response.json()) as T; } +function isAbortError(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + if (error instanceof Error && error.name === "AbortError") { + return true; + } + return false; +} + async function fetchJsonWithFailover( primaryUrl: string, backendPath: string, @@ -60,11 +70,11 @@ async function fetchJsonWithFailover( ): Promise { try { return await fetchJson(primaryUrl, init); - } catch { + } catch (error) { + if (isAbortError(error)) { + throw error; + } const backendUrl = getBackendAlchemyProxyUrl(backendPath); - console.warn( - `Failed to fetch from primary endpoint (${primaryUrl}), falling back to proxy endpoint: (${backendUrl})` - ); return fetchJson(backendUrl, init); } } From 1008b8de60ad8a8b32e59d63f24ffc9101e9d372 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Mon, 15 Dec 2025 17:45:28 +0200 Subject: [PATCH 03/15] WIP Signed-off-by: prxt6529 --- __tests__/hooks/useAlchemyNftQueries.test.ts | 71 +------------------- 1 file changed, 2 insertions(+), 69 deletions(-) diff --git a/__tests__/hooks/useAlchemyNftQueries.test.ts b/__tests__/hooks/useAlchemyNftQueries.test.ts index e779f499e1..b6f6c77c68 100644 --- a/__tests__/hooks/useAlchemyNftQueries.test.ts +++ b/__tests__/hooks/useAlchemyNftQueries.test.ts @@ -1,10 +1,6 @@ import { fetchOwnerNfts } from "@/hooks/useAlchemyNftQueries"; -const mockPublicEnv = { - API_ENDPOINT: "https://api.example.com", - BASE_ENDPOINT: "https://example.com", - ALLOWLIST_API_ENDPOINT: "https://allowlist.example.com", -}; +const MOCK_API_ENDPOINT = "https://api.example.com"; jest.mock("@/config/env", () => ({ publicEnv: { @@ -75,7 +71,7 @@ describe("useAlchemyNftQueries", () => { ); expect(global.fetch).toHaveBeenNthCalledWith( 2, - `${mockPublicEnv.API_ENDPOINT}/alchemy-proxy/owner-nfts?chainId=1&contract=0x123&owner=0xowner`, + `${MOCK_API_ENDPOINT}/alchemy-proxy/owner-nfts?chainId=1&contract=0x123&owner=0xowner`, { signal: undefined } ); }); @@ -161,67 +157,4 @@ describe("useAlchemyNftQueries", () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); }); - - describe("fetchJsonWithFailover (via fetchOwnerNfts)", () => { - it("should fallback when primary returns ALCHEMY_API_KEY error", async () => { - const mockNfts = [ - { - tokenId: "1", - tokenType: "ERC721", - name: null, - tokenUri: null, - image: null, - }, - ]; - - global.fetch = jest - .fn() - .mockResolvedValueOnce({ - ok: false, - status: 400, - json: () => - Promise.resolve({ - error: - "ALCHEMY_API_KEY is required and must be a non-empty string", - }), - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockNfts), - }); - - const result = await fetchOwnerNfts(1, "0x123", "0xowner"); - - expect(result).toEqual(mockNfts); - expect(global.fetch).toHaveBeenCalledTimes(2); - }); - - it("should fallback when primary returns 500 server error", async () => { - const mockNfts = [ - { - tokenId: "1", - tokenType: "ERC721", - name: null, - tokenUri: null, - image: null, - }, - ]; - - global.fetch = jest - .fn() - .mockResolvedValueOnce({ - ok: false, - status: 500, - }) - .mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve(mockNfts), - }); - - const result = await fetchOwnerNfts(1, "0x123", "0xowner"); - - expect(result).toEqual(mockNfts); - expect(global.fetch).toHaveBeenCalledTimes(2); - }); - }); }); From 93cba5e9ea265033698d406735ab753bd5e7ed75 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Tue, 16 Dec 2025 09:33:26 +0200 Subject: [PATCH 04/15] WIP Signed-off-by: prxt6529 --- __tests__/hooks/useAlchemyNftQueries.test.ts | 42 ++++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/__tests__/hooks/useAlchemyNftQueries.test.ts b/__tests__/hooks/useAlchemyNftQueries.test.ts index b6f6c77c68..dd40620a35 100644 --- a/__tests__/hooks/useAlchemyNftQueries.test.ts +++ b/__tests__/hooks/useAlchemyNftQueries.test.ts @@ -11,14 +11,14 @@ jest.mock("@/config/env", () => ({ })); describe("useAlchemyNftQueries", () => { - const originalFetch = global.fetch; + const originalFetch = globalThis.fetch; beforeEach(() => { jest.clearAllMocks(); }); afterEach(() => { - global.fetch = originalFetch; + globalThis.fetch = originalFetch; }); describe("fetchOwnerNfts", () => { @@ -33,7 +33,7 @@ describe("useAlchemyNftQueries", () => { ]; it("should return data from primary endpoint when successful", async () => { - global.fetch = jest.fn().mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockNfts), }); @@ -41,15 +41,15 @@ describe("useAlchemyNftQueries", () => { const result = await fetchOwnerNfts(1, "0x123", "0xowner"); expect(result).toEqual(mockNfts); - expect(global.fetch).toHaveBeenCalledTimes(1); - expect(global.fetch).toHaveBeenCalledWith( + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( "/api/alchemy/owner-nfts?chainId=1&contract=0x123&owner=0xowner", { signal: undefined } ); }); it("should fallback to backend proxy when primary endpoint fails with non-ok response", async () => { - global.fetch = jest + globalThis.fetch = jest .fn() .mockResolvedValueOnce({ ok: false, @@ -63,13 +63,13 @@ describe("useAlchemyNftQueries", () => { const result = await fetchOwnerNfts(1, "0x123", "0xowner"); expect(result).toEqual(mockNfts); - expect(global.fetch).toHaveBeenCalledTimes(2); - expect(global.fetch).toHaveBeenNthCalledWith( + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect(globalThis.fetch).toHaveBeenNthCalledWith( 1, "/api/alchemy/owner-nfts?chainId=1&contract=0x123&owner=0xowner", { signal: undefined } ); - expect(global.fetch).toHaveBeenNthCalledWith( + expect(globalThis.fetch).toHaveBeenNthCalledWith( 2, `${MOCK_API_ENDPOINT}/alchemy-proxy/owner-nfts?chainId=1&contract=0x123&owner=0xowner`, { signal: undefined } @@ -77,7 +77,7 @@ describe("useAlchemyNftQueries", () => { }); it("should fallback to backend proxy when primary endpoint throws network error", async () => { - global.fetch = jest + globalThis.fetch = jest .fn() .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce({ @@ -88,11 +88,11 @@ describe("useAlchemyNftQueries", () => { const result = await fetchOwnerNfts(1, "0x123", "0xowner"); expect(result).toEqual(mockNfts); - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); it("should throw error when both primary and fallback fail", async () => { - global.fetch = jest + globalThis.fetch = jest .fn() .mockResolvedValueOnce({ ok: false, @@ -106,32 +106,32 @@ describe("useAlchemyNftQueries", () => { await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow( "Request failed with status 500" ); - expect(global.fetch).toHaveBeenCalledTimes(2); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); it("should pass abort signal to fetch calls", async () => { const controller = new AbortController(); - global.fetch = jest.fn().mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockNfts), }); await fetchOwnerNfts(1, "0x123", "0xowner", controller.signal); - expect(global.fetch).toHaveBeenCalledWith(expect.any(String), { + expect(globalThis.fetch).toHaveBeenCalledWith(expect.any(String), { signal: controller.signal, }); }); it("should handle different chain IDs correctly", async () => { - global.fetch = jest.fn().mockResolvedValueOnce({ + globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockNfts), }); await fetchOwnerNfts(11155111, "0x123", "0xowner"); - expect(global.fetch).toHaveBeenCalledWith( + expect(globalThis.fetch).toHaveBeenCalledWith( "/api/alchemy/owner-nfts?chainId=11155111&contract=0x123&owner=0xowner", { signal: undefined } ); @@ -142,19 +142,19 @@ describe("useAlchemyNftQueries", () => { "The operation was aborted.", "AbortError" ); - global.fetch = jest.fn().mockRejectedValueOnce(abortError); + globalThis.fetch = jest.fn().mockRejectedValueOnce(abortError); await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow(); - expect(global.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); it("should NOT fallback when request throws Error with name AbortError", async () => { const abortError = new Error("The operation was aborted."); abortError.name = "AbortError"; - global.fetch = jest.fn().mockRejectedValueOnce(abortError); + globalThis.fetch = jest.fn().mockRejectedValueOnce(abortError); await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow(); - expect(global.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); }); }); From e267d46412de5dd2d1b2b138473c6f1311926191 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Tue, 16 Dec 2025 09:37:21 +0200 Subject: [PATCH 05/15] WIP Signed-off-by: prxt6529 --- hooks/useAlchemyNftQueries.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hooks/useAlchemyNftQueries.ts b/hooks/useAlchemyNftQueries.ts index de062f84a0..5eb9614cc9 100644 --- a/hooks/useAlchemyNftQueries.ts +++ b/hooks/useAlchemyNftQueries.ts @@ -75,6 +75,9 @@ async function fetchJsonWithFailover( throw error; } const backendUrl = getBackendAlchemyProxyUrl(backendPath); + console.warn( + `Failed to fetch from primary endpoint (${primaryUrl}), falling back to proxy endpoint: (${backendUrl})` + ); return fetchJson(backendUrl, init); } } From f638c2b3d25e79dc4bca72f877f29ae18d68a78d Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Tue, 16 Dec 2025 10:26:45 +0200 Subject: [PATCH 06/15] WIP Signed-off-by: prxt6529 --- __tests__/hooks/useAlchemyNftQueries.test.ts | 77 +++- app/api/alchemy/collections/route.ts | 44 +- app/api/alchemy/contract/route.ts | 48 +- app/api/alchemy/owner-nfts/route.ts | 39 +- app/api/alchemy/token-metadata/route.ts | 95 +++- .../mint/NextGenMintBurnWidget.tsx | 40 +- helpers/alchemy/response-processing.ts | 435 ++++++++++++++++++ hooks/useAlchemyNftQueries.ts | 105 +++-- 8 files changed, 758 insertions(+), 125 deletions(-) create mode 100644 helpers/alchemy/response-processing.ts diff --git a/__tests__/hooks/useAlchemyNftQueries.test.ts b/__tests__/hooks/useAlchemyNftQueries.test.ts index dd40620a35..c949c154ab 100644 --- a/__tests__/hooks/useAlchemyNftQueries.test.ts +++ b/__tests__/hooks/useAlchemyNftQueries.test.ts @@ -22,7 +22,20 @@ describe("useAlchemyNftQueries", () => { }); describe("fetchOwnerNfts", () => { - const mockNfts = [ + const mockAlchemyResponse = { + ownedNfts: [ + { + tokenId: "1", + tokenType: "ERC721", + name: "Test NFT", + tokenUri: "https://example.com/1", + image: null, + }, + ], + pageKey: undefined, + }; + + const expectedProcessedResult = [ { tokenId: "1", tokenType: "ERC721", @@ -32,15 +45,15 @@ describe("useAlchemyNftQueries", () => { }, ]; - it("should return data from primary endpoint when successful", async () => { + it("should return processed data from primary endpoint when successful", async () => { globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockNfts), + json: () => Promise.resolve(mockAlchemyResponse), }); const result = await fetchOwnerNfts(1, "0x123", "0xowner"); - expect(result).toEqual(mockNfts); + expect(result).toEqual(expectedProcessedResult); expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(globalThis.fetch).toHaveBeenCalledWith( "/api/alchemy/owner-nfts?chainId=1&contract=0x123&owner=0xowner", @@ -57,12 +70,12 @@ describe("useAlchemyNftQueries", () => { }) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockNfts), + json: () => Promise.resolve(mockAlchemyResponse), }); const result = await fetchOwnerNfts(1, "0x123", "0xowner"); - expect(result).toEqual(mockNfts); + expect(result).toEqual(expectedProcessedResult); expect(globalThis.fetch).toHaveBeenCalledTimes(2); expect(globalThis.fetch).toHaveBeenNthCalledWith( 1, @@ -82,12 +95,12 @@ describe("useAlchemyNftQueries", () => { .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockNfts), + json: () => Promise.resolve(mockAlchemyResponse), }); const result = await fetchOwnerNfts(1, "0x123", "0xowner"); - expect(result).toEqual(mockNfts); + expect(result).toEqual(expectedProcessedResult); expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); @@ -113,7 +126,7 @@ describe("useAlchemyNftQueries", () => { const controller = new AbortController(); globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockNfts), + json: () => Promise.resolve(mockAlchemyResponse), }); await fetchOwnerNfts(1, "0x123", "0xowner", controller.signal); @@ -126,7 +139,7 @@ describe("useAlchemyNftQueries", () => { it("should handle different chain IDs correctly", async () => { globalThis.fetch = jest.fn().mockResolvedValueOnce({ ok: true, - json: () => Promise.resolve(mockNfts), + json: () => Promise.resolve(mockAlchemyResponse), }); await fetchOwnerNfts(11155111, "0x123", "0xowner"); @@ -156,5 +169,49 @@ describe("useAlchemyNftQueries", () => { await expect(fetchOwnerNfts(1, "0x123", "0xowner")).rejects.toThrow(); expect(globalThis.fetch).toHaveBeenCalledTimes(1); }); + + it("should process raw Alchemy response correctly", async () => { + const rawAlchemyResponse = { + ownedNfts: [ + { + tokenId: "123", + tokenType: "ERC1155", + name: null, + tokenUri: null, + image: { thumbnailUrl: "https://img.example.com/123.png" }, + }, + { + tokenId: "456", + tokenType: "ERC721", + name: "Cool NFT", + tokenUri: "https://metadata.example.com/456", + image: null, + }, + ], + }; + + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(rawAlchemyResponse), + }); + + const result = await fetchOwnerNfts(1, "0x123", "0xowner"); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + tokenId: "123", + tokenType: "ERC1155", + name: null, + tokenUri: null, + image: { thumbnailUrl: "https://img.example.com/123.png" }, + }); + expect(result[1]).toEqual({ + tokenId: "456", + tokenType: "ERC721", + name: "Cool NFT", + tokenUri: "https://metadata.example.com/456", + image: null, + }); + }); }); }); diff --git a/app/api/alchemy/collections/route.ts b/app/api/alchemy/collections/route.ts index 10f6ed4fff..5e0f01ab3e 100644 --- a/app/api/alchemy/collections/route.ts +++ b/app/api/alchemy/collections/route.ts @@ -1,10 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; -import { searchNftCollections } from "@/services/alchemy-api"; +import { getAlchemyApiKey } from "@/config/alchemyEnv"; import type { SupportedChain } from "@/types/nft"; const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; +const NETWORK_MAP: Record = { + ethereum: "eth-mainnet", +}; + +function resolveNetwork(chain: SupportedChain = "ethereum"): string { + return NETWORK_MAP[chain] ?? NETWORK_MAP.ethereum; +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const query = searchParams.get("query") ?? ""; @@ -16,22 +24,38 @@ export async function GET(request: NextRequest) { } const chain = (searchParams.get("chain") ?? "ethereum") as SupportedChain; - const hideSpam = searchParams.get("hideSpam") !== "0" && - searchParams.get("hideSpam") !== "false"; const pageKey = searchParams.get("pageKey") ?? undefined; try { - const result = await searchNftCollections({ - query, - chain, - hideSpam, - pageKey, + const apiKey = getAlchemyApiKey(); + const network = resolveNetwork(chain); + const url = new URL( + `https://${network}.g.alchemy.com/nft/v3/${apiKey}/searchContractMetadata` + ); + url.searchParams.set("query", query.trim()); + if (pageKey) { + url.searchParams.set("pageKey", pageKey); + } + + const response = await fetch(url.toString(), { + headers: { Accept: "application/json" }, signal: request.signal, }); - return NextResponse.json(result, { headers: NO_STORE_HEADERS }); + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to search NFT collections" }, + { status: response.status, headers: NO_STORE_HEADERS } + ); + } + + const payload = await response.json(); + return NextResponse.json(payload, { headers: NO_STORE_HEADERS }); } catch (error) { const message = - error instanceof Error ? error.message : "Failed to search NFT collections"; + error instanceof Error + ? error.message + : "Failed to search NFT collections"; return NextResponse.json( { error: message }, { status: 400, headers: NO_STORE_HEADERS } diff --git a/app/api/alchemy/contract/route.ts b/app/api/alchemy/contract/route.ts index 88ea682e1f..0e51332658 100644 --- a/app/api/alchemy/contract/route.ts +++ b/app/api/alchemy/contract/route.ts @@ -1,29 +1,63 @@ import { NextRequest, NextResponse } from "next/server"; -import { getContractOverview } from "@/services/alchemy-api"; +import { getAlchemyApiKey } from "@/config/alchemyEnv"; +import { isValidEthAddress } from "@/helpers/Helpers"; +import { normaliseAddress } from "@/helpers/alchemy/response-processing"; import type { SupportedChain } from "@/types/nft"; const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; +const NETWORK_MAP: Record = { + ethereum: "eth-mainnet", +}; + +function resolveNetwork(chain: SupportedChain = "ethereum"): string { + return NETWORK_MAP[chain] ?? NETWORK_MAP.ethereum; +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - const address = searchParams.get("address") as `0x${string}` | null; - if (!address) { + const address = searchParams.get("address"); + if (!address || !isValidEthAddress(address)) { return NextResponse.json( { error: "address is required" }, { status: 400, headers: NO_STORE_HEADERS } ); } + const checksum = normaliseAddress(address); + if (!checksum) { + return NextResponse.json(null, { headers: NO_STORE_HEADERS }); + } + const chain = (searchParams.get("chain") ?? "ethereum") as SupportedChain; try { - const overview = await getContractOverview({ - address, - chain, + const apiKey = getAlchemyApiKey(); + const network = resolveNetwork(chain); + const url = `https://${network}.g.alchemy.com/nft/v3/${apiKey}/getContractMetadata?contractAddress=${checksum}`; + + const response = await fetch(url, { + headers: { Accept: "application/json" }, signal: request.signal, }); - return NextResponse.json(overview, { headers: NO_STORE_HEADERS }); + + if (response.status === 404) { + return NextResponse.json(null, { headers: NO_STORE_HEADERS }); + } + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch contract metadata" }, + { status: response.status, headers: NO_STORE_HEADERS } + ); + } + + const payload = await response.json(); + return NextResponse.json( + { ...payload, _checksum: checksum }, + { headers: NO_STORE_HEADERS } + ); } catch (error) { const message = error instanceof Error ? error.message : "Failed to fetch contract metadata"; diff --git a/app/api/alchemy/owner-nfts/route.ts b/app/api/alchemy/owner-nfts/route.ts index 1df37f3d98..036e85d11d 100644 --- a/app/api/alchemy/owner-nfts/route.ts +++ b/app/api/alchemy/owner-nfts/route.ts @@ -1,9 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; -import { getNftsForContractAndOwner } from "@/services/alchemy-api"; +import { getAlchemyApiKey } from "@/config/alchemyEnv"; const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; +function resolveNetworkByChainId(chainId: number): string { + if (chainId === 11155111) return "eth-sepolia"; + if (chainId === 5) return "eth-goerli"; + return "eth-mainnet"; +} + export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const chainIdRaw = searchParams.get("chainId"); @@ -27,16 +33,27 @@ export async function GET(request: NextRequest) { } try { - const nfts = await getNftsForContractAndOwner( - chainId, - contract, - owner, - [], - pageKey, - 0, - request.signal - ); - return NextResponse.json(nfts, { headers: NO_STORE_HEADERS }); + const apiKey = getAlchemyApiKey(); + const network = resolveNetworkByChainId(chainId); + let url = `https://${network}.g.alchemy.com/nft/v3/${apiKey}/getNFTsForOwner?owner=${owner}&contractAddresses[]=${contract}`; + if (pageKey) { + url += `&pageKey=${pageKey}`; + } + + const response = await fetch(url, { + headers: { accept: "application/json" }, + signal: request.signal, + }); + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch NFTs for owner" }, + { status: response.status, headers: NO_STORE_HEADERS } + ); + } + + const payload = await response.json(); + return NextResponse.json(payload, { headers: NO_STORE_HEADERS }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to fetch NFTs for owner"; diff --git a/app/api/alchemy/token-metadata/route.ts b/app/api/alchemy/token-metadata/route.ts index 19c473520c..9d6514927b 100644 --- a/app/api/alchemy/token-metadata/route.ts +++ b/app/api/alchemy/token-metadata/route.ts @@ -1,21 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; -import { getTokensMetadata } from "@/services/alchemy-api"; -import type { SupportedChain, TokenMetadata } from "@/types/nft"; +import { getAlchemyApiKey } from "@/config/alchemyEnv"; +import { isValidEthAddress } from "@/helpers/Helpers"; +import { normaliseAddress } from "@/helpers/alchemy/response-processing"; +import type { SupportedChain } from "@/types/nft"; const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; +const MAX_BATCH_SIZE = 100; + +const NETWORK_MAP: Record = { + ethereum: "eth-mainnet", +}; + +function resolveNetwork(chain: SupportedChain = "ethereum"): string { + return NETWORK_MAP[chain] ?? NETWORK_MAP.ethereum; +} type TokenMetadataRequestBody = { - readonly address?: `0x${string}`; + readonly address?: string; readonly tokenIds?: string[]; readonly tokens?: { contract: string; tokenId: string }[]; readonly chain?: SupportedChain; }; -type SerializableTokenMetadata = Omit & { - readonly tokenId: string; -}; - export async function POST(request: NextRequest) { let body: TokenMetadataRequestBody; try { @@ -29,10 +36,31 @@ export async function POST(request: NextRequest) { const { address, tokenIds, tokens, chain = "ethereum" } = body ?? {}; - if ( - (!tokens || tokens.length === 0) && - (!address || !Array.isArray(tokenIds) || tokenIds.length === 0) - ) { + let tokensToFetch: { contractAddress: string; tokenId: string }[] = []; + + if (tokens && tokens.length > 0) { + tokensToFetch = tokens.map((t) => ({ + contractAddress: t.contract, + tokenId: t.tokenId, + })); + } else if (address && Array.isArray(tokenIds) && tokenIds.length > 0) { + if (!isValidEthAddress(address)) { + return NextResponse.json( + { error: "Invalid contract address" }, + { status: 400, headers: NO_STORE_HEADERS } + ); + } + const checksum = normaliseAddress(address); + if (!checksum) { + return NextResponse.json({ tokens: [] }, { headers: NO_STORE_HEADERS }); + } + tokensToFetch = tokenIds.map((tokenId) => ({ + contractAddress: checksum, + tokenId, + })); + } + + if (tokensToFetch.length === 0) { return NextResponse.json( { error: "Either tokens OR (address and tokenIds) are required" }, { status: 400, headers: NO_STORE_HEADERS } @@ -40,20 +68,37 @@ export async function POST(request: NextRequest) { } try { - const metadata = await getTokensMetadata({ - address, - tokenIds, - tokens, - chain, - signal: request.signal, - }); - const serializable: SerializableTokenMetadata[] = metadata.map( - (entry) => ({ - ...entry, - tokenId: entry.tokenId.toString(), - }) - ); - return NextResponse.json(serializable, { headers: NO_STORE_HEADERS }); + const apiKey = getAlchemyApiKey(); + const network = resolveNetwork(chain); + const url = `https://${network}.g.alchemy.com/nft/v3/${apiKey}/getNFTMetadataBatch`; + const allTokens: unknown[] = []; + + for (let i = 0; i < tokensToFetch.length; i += MAX_BATCH_SIZE) { + const slice = tokensToFetch.slice(i, i + MAX_BATCH_SIZE); + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ tokens: slice }), + signal: request.signal, + }); + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch token metadata" }, + { status: response.status, headers: NO_STORE_HEADERS } + ); + } + + const payload = await response.json(); + const tokens = payload.tokens ?? payload.nfts ?? []; + allTokens.push(...tokens); + } + + return NextResponse.json({ tokens: allTokens }, { headers: NO_STORE_HEADERS }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to fetch token metadata"; diff --git a/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx b/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx index 8b0a2d5736..f17f0bf6be 100644 --- a/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx +++ b/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.tsx @@ -1,21 +1,8 @@ "use client"; -import { publicEnv } from "@/config/env"; -import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useEffect, useState } from "react"; -import { Button, Col, Container, Form, Row, Table } from "react-bootstrap"; -import { Tooltip } from "react-tooltip"; -import { useChainId, useWriteContract } from "wagmi"; -import { NextGenCollection } from "@/entities/INextgen"; -import { - areEqualAddresses, - getNetworkName, -} from "@/helpers/Helpers"; -import { fetchOwnerNfts } from "@/hooks/useAlchemyNftQueries"; -import { fetchUrl } from "@/services/6529api"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import NextGenContractWriteStatus from "@/components/nextGen/NextGenContractWriteStatus"; +import styles from "@/components/nextGen/collections/NextGen.module.scss"; import { NEXTGEN_CHAIN_ID, NEXTGEN_CORE, @@ -31,7 +18,17 @@ import { getStatusFromDates, useMintSharedState, } from "@/components/nextGen/nextgen_helpers"; -import styles from "@/components/nextGen/collections/NextGen.module.scss"; +import { publicEnv } from "@/config/env"; +import { NextGenCollection } from "@/entities/INextgen"; +import { areEqualAddresses, getNetworkName } from "@/helpers/Helpers"; +import { fetchOwnerNfts, type OwnerNft } from "@/hooks/useAlchemyNftQueries"; +import { fetchUrl } from "@/services/6529api"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useEffect, useState } from "react"; +import { Button, Col, Container, Form, Row, Table } from "react-bootstrap"; +import { Tooltip } from "react-tooltip"; +import { useChainId, useWriteContract } from "wagmi"; import { Spinner } from "./NextGenMint"; import { NextGenMintingFor } from "./NextGenMintShared"; @@ -81,15 +78,16 @@ export default function NextGenMintBurnWidget(props: Readonly) { const [tokensOwnedForBurnAddressLoaded, setTokensOwnedForBurnAddressLoaded] = useState(false); const [tokensOwnedForBurnAddress, setTokensOwnedForBurnAddress] = useState< - any[] + OwnerNft[] >([]); - function filterTokensOwnedForBurnAddress(r: any[]) { + function filterTokensOwnedForBurnAddress(r: OwnerNft[]) { if (props.collection_merkle.max_token_index > 0) { r = r.filter((t) => { + const tokenIdNum = Number(t.tokenId); return ( - t.tokenId >= props.collection_merkle.min_token_index && - t.tokenId <= props.collection_merkle.max_token_index + tokenIdNum >= props.collection_merkle.min_token_index && + tokenIdNum <= props.collection_merkle.max_token_index ); }); } @@ -100,9 +98,7 @@ export default function NextGenMintBurnWidget(props: Readonly) { ) ) { r = r.filter((t) => - t.tokenId - .toString() - .startsWith(props.collection_merkle.burn_collection_id) + t.tokenId.startsWith(String(props.collection_merkle.burn_collection_id)) ); } return r; diff --git a/helpers/alchemy/response-processing.ts b/helpers/alchemy/response-processing.ts new file mode 100644 index 0000000000..f6f3683253 --- /dev/null +++ b/helpers/alchemy/response-processing.ts @@ -0,0 +1,435 @@ +import { getAddress } from "viem"; + +import { isValidEthAddress } from "@/helpers/Helpers"; +import type { + ContractOverview, + Suggestion, + TokenMetadata, +} from "@/types/nft"; + +export type AlchemyOpenSeaMetadata = { + imageUrl?: string | null; + bannerImageUrl?: string | null; + collectionName?: string | null; + safelistRequestStatus?: string | null; + floorPrice?: number | { eth?: number | string | null } | null; + description?: string | null; +}; + +export type AlchemyContractMetadata = { + address?: string | null; + name?: string | null; + symbol?: string | null; + tokenType?: string | null; + totalSupply?: string | null; + image?: { cachedUrl?: string | null; thumbnailUrl?: string | null } | null; + bannerImageUrl?: string | null; + description?: string | null; + contractDeployer?: string | null; + deployedBlockNumber?: number | null; + openSeaMetadata?: AlchemyOpenSeaMetadata; + openseaMetadata?: AlchemyOpenSeaMetadata; + openSea?: AlchemyOpenSeaMetadata; + opensea?: AlchemyOpenSeaMetadata; + isSpam?: boolean; + spamInfo?: { isSpam?: boolean }; +}; + +export type AlchemyContractResult = { + address?: string; + contractAddress?: string; + contractDeployer?: string | null; + isSpam?: boolean; + spamInfo?: { isSpam?: boolean }; + spamClassifications?: string[] | null; + contractMetadata?: AlchemyContractMetadata; + openSeaMetadata?: AlchemyOpenSeaMetadata; + openseaMetadata?: AlchemyOpenSeaMetadata; +} & AlchemyContractMetadata; + +export type AlchemySearchResponse = { + contracts?: AlchemyContractResult[]; + pageKey?: string; +}; + +export type AlchemyContractMetadataResponse = AlchemyContractMetadata & { + contractMetadata?: AlchemyContractMetadata | null; + openSeaMetadata?: AlchemyOpenSeaMetadata | null; + openseaMetadata?: AlchemyOpenSeaMetadata | null; + address?: string | null; + contractAddress?: string | null; + isSpam?: boolean; +}; + +export type AlchemyNftMedia = { + cachedUrl?: string | null; + thumbnailUrl?: string | null; + originalUrl?: string | null; + pngUrl?: string | null; + gateway?: string | null; + raw?: string | null; + contentType?: string | null; + size?: number | null; +}; + +export type AlchemyNftMetadata = { + image?: string | null; + name?: string | null; + description?: string | null; + [key: string]: unknown; +}; + +export type AlchemyTokenMetadataEntry = { + contract?: (AlchemyContractMetadata & { spamClassifications?: string[] | null }) | null; + tokenId?: string; + tokenType?: string | null; + title?: string | null; + name?: string | null; + description?: string | null; + tokenUri?: string | null; + image?: AlchemyNftMedia | null; + animation?: AlchemyNftMedia | null; + media?: AlchemyNftMedia[] | null; + metadata?: AlchemyNftMetadata | null; + raw?: { + tokenUri?: string | null; + metadata?: AlchemyNftMetadata | null; + error?: string | null; + } | null; + collection?: { name?: string | null } | null; + isSpam?: boolean; + spamInfo?: { isSpam?: boolean } | null; +}; + +export type AlchemyTokenMetadataResponse = { + tokens?: AlchemyTokenMetadataEntry[]; + nfts?: AlchemyTokenMetadataEntry[]; +}; + +export type AlchemyOwnedNft = AlchemyTokenMetadataEntry & { + balance?: string | null; +}; + +export type AlchemyGetNftsForOwnerResponse = { + ownedNfts: AlchemyOwnedNft[]; + pageKey?: string; + totalCount?: number; + error?: { code?: number; message?: string }; +}; + +type OpenSeaMetadataSource = { + openSeaMetadata?: AlchemyOpenSeaMetadata | null; + openseaMetadata?: AlchemyOpenSeaMetadata | null; + openSea?: AlchemyOpenSeaMetadata | null; + opensea?: AlchemyOpenSeaMetadata | null; +} | null | undefined; + +export function normaliseAddress( + address?: string | null +): `0x${string}` | null { + if (!address) { + return null; + } + if (!isValidEthAddress(address)) { + return null; + } + try { + return getAddress(address); + } catch { + return address as `0x${string}`; + } +} + +function resolveOpenSeaMetadata( + ...sources: OpenSeaMetadataSource[] +): AlchemyOpenSeaMetadata | undefined { + for (const source of sources) { + if (!source) { + continue; + } + const metadata = + source.openSeaMetadata ?? + source.openseaMetadata ?? + source.openSea ?? + source.opensea; + if (metadata) { + return metadata; + } + } + return undefined; +} + +function pickImage(source?: { + image?: { cachedUrl?: string | null; thumbnailUrl?: string | null } | null; + imageUrl?: string | null; + media?: { thumbnailUrl?: string | null; gateway?: string | null }[] | null; +}): string | null { + if (!source) { + return null; + } + if (source.imageUrl) { + return source.imageUrl; + } + if (source.image?.cachedUrl) { + return source.image.cachedUrl; + } + if (source.image?.thumbnailUrl) { + return source.image.thumbnailUrl; + } + if (source.media && source.media.length > 0) { + const mediaItem = source.media.find((item) => item?.thumbnailUrl) ?? + source.media[0]; + if (mediaItem?.thumbnailUrl) { + return mediaItem.thumbnailUrl; + } + if (mediaItem?.gateway) { + return mediaItem.gateway; + } + } + return null; +} + +function pickThumbnail(source?: { + image?: { thumbnailUrl?: string | null; cachedUrl?: string | null } | null; + media?: { thumbnailUrl?: string | null }[] | null; +}): string | null { + if (!source) { + return null; + } + if (source.image?.thumbnailUrl) { + return source.image.thumbnailUrl; + } + if (source.image?.cachedUrl) { + return source.image.cachedUrl; + } + if (source.media && source.media.length > 0) { + const mediaItem = source.media.find((item) => item?.thumbnailUrl) ?? + source.media[0]; + if (mediaItem?.thumbnailUrl) { + return mediaItem.thumbnailUrl; + } + } + return null; +} + +function toSafelist( + status: string | null | undefined +): Suggestion["safelist"] { + if (!status) { + return undefined; + } + if ( + status === "verified" || + status === "approved" || + status === "requested" || + status === "not_requested" + ) { + return status; + } + return undefined; +} + +function parseFloorPrice( + meta: AlchemyOpenSeaMetadata | undefined +): number | null { + if (!meta) { + return null; + } + const { floorPrice } = meta; + if (typeof floorPrice === "number") { + return floorPrice; + } + if (floorPrice && typeof floorPrice === "object") { + const candidate = floorPrice.eth; + if (typeof candidate === "number") { + return candidate; + } + if (typeof candidate === "string") { + const parsed = Number(candidate); + return Number.isFinite(parsed) ? parsed : null; + } + } + return null; +} + +function extractContract(contract: AlchemyContractResult): Suggestion | null { + const baseMeta = contract.contractMetadata ?? contract; + const openSea = resolveOpenSeaMetadata(contract, baseMeta); + const address = + normaliseAddress(contract.address ?? contract.contractAddress) ?? + normaliseAddress(baseMeta.address); + if (!address) { + return null; + } + const name = baseMeta.name ?? openSea?.collectionName ?? undefined; + const totalSupply = baseMeta.totalSupply ?? undefined; + const tokenType = baseMeta.tokenType?.toUpperCase() as + | "ERC721" + | "ERC1155" + | undefined; + const imageUrl = pickImage({ + imageUrl: openSea?.imageUrl ?? undefined, + image: baseMeta.image, + }); + const isSpam = + contract.isSpam ?? + contract.spamInfo?.isSpam ?? + baseMeta.isSpam ?? + baseMeta.spamInfo?.isSpam ?? + false; + const safelist = toSafelist(openSea?.safelistRequestStatus ?? null); + const floorPriceEth = parseFloorPrice(openSea ?? undefined); + const deployer = normaliseAddress( + contract.contractDeployer ?? baseMeta.contractDeployer ?? null + ); + + return { + address, + name: name ?? undefined, + symbol: baseMeta.symbol ?? undefined, + tokenType, + totalSupply: totalSupply ?? undefined, + floorPriceEth, + imageUrl: imageUrl ?? null, + isSpam: isSpam ?? false, + safelist, + deployer: deployer ?? null, + }; +} + +export type SearchContractsResult = { + items: Suggestion[]; + hiddenCount: number; + nextPageKey?: string; +}; + +export function processSearchResponse( + payload: AlchemySearchResponse | undefined, + hideSpam: boolean +): SearchContractsResult { + const contracts = payload?.contracts ?? []; + const suggestions = contracts + .map((contract) => extractContract(contract)) + .filter((suggestion): suggestion is Suggestion => suggestion !== null); + const hiddenCount = hideSpam + ? suggestions.filter((suggestion) => suggestion.isSpam).length + : 0; + const visibleItems = hideSpam + ? suggestions.filter((suggestion) => !suggestion.isSpam) + : suggestions; + return { + items: visibleItems, + hiddenCount, + nextPageKey: payload?.pageKey, + }; +} + +export function processContractMetadataResponse( + payload: AlchemyContractMetadataResponse | undefined, + checksum: `0x${string}` +): ContractOverview | null { + if (!payload) { + return null; + } + const baseMeta: AlchemyContractMetadata = + payload.contractMetadata ?? payload; + const openSeaMetadata = resolveOpenSeaMetadata( + payload, + payload.contractMetadata, + baseMeta + ); + const contract: AlchemyContractResult = { + ...baseMeta, + contractMetadata: baseMeta, + address: checksum, + contractAddress: checksum, + openSeaMetadata, + isSpam: + payload.isSpam ?? baseMeta.isSpam ?? baseMeta.spamInfo?.isSpam, + }; + const suggestion = extractContract(contract); + if (!suggestion) { + return null; + } + return { + ...suggestion, + description: openSeaMetadata?.description ?? null, + bannerImageUrl: openSeaMetadata?.bannerImageUrl ?? null, + }; +} + +export type OwnerNft = { + tokenId: string; + tokenType: string | null; + name: string | null; + tokenUri: string | null; + image: AlchemyNftMedia | null; +}; + +export function processOwnerNftsResponse( + ownedNfts: AlchemyOwnedNft[] +): OwnerNft[] { + return ownedNfts.map((nft) => ({ + tokenId: nft.tokenId ?? "", + tokenType: nft.tokenType ?? null, + name: nft.name ?? null, + tokenUri: nft.tokenUri ?? null, + image: nft.image ?? null, + })); +} + +function parseTokenIdToBigint(tokenId: string): bigint { + if (!tokenId) { + throw new Error("Token ID missing"); + } + const trimmed = tokenId.trim(); + return BigInt(trimmed); +} + +function normaliseTokenMetadata( + token: AlchemyTokenMetadataEntry +): TokenMetadata | null { + const tokenIdRaw = token.tokenId ?? ""; + try { + const tokenId = parseTokenIdToBigint(tokenIdRaw); + const imageUrl = pickThumbnail({ + image: token.image ?? undefined, + media: token.media ?? undefined, + }); + return { + tokenId, + tokenIdRaw, + contract: token.contract?.address ?? undefined, + name: + token.title ?? + token.name ?? + token.metadata?.name ?? + token.raw?.metadata?.name ?? + null, + imageUrl, + collectionName: + token.collection?.name ?? + token.contract?.openSeaMetadata?.collectionName ?? + token.contract?.name ?? + null, + isSpam: token.isSpam ?? token.spamInfo?.isSpam ?? false, + }; + } catch { + return null; + } +} + +export function processTokenMetadataResponse( + payload: AlchemyTokenMetadataResponse +): TokenMetadata[] { + const tokens = payload.tokens ?? payload.nfts ?? []; + const results: TokenMetadata[] = []; + for (const token of tokens) { + const normalised = normaliseTokenMetadata(token); + if (normalised) { + results.push(normalised); + } + } + return results; +} + diff --git a/hooks/useAlchemyNftQueries.ts b/hooks/useAlchemyNftQueries.ts index 5eb9614cc9..4bb3e163b3 100644 --- a/hooks/useAlchemyNftQueries.ts +++ b/hooks/useAlchemyNftQueries.ts @@ -9,11 +9,20 @@ import { useEffect, useMemo } from "react"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; import { publicEnv } from "@/config/env"; +import { + normaliseAddress, + processContractMetadataResponse, + processOwnerNftsResponse, + processSearchResponse, + processTokenMetadataResponse, + type AlchemyContractMetadataResponse, + type AlchemyGetNftsForOwnerResponse, + type AlchemySearchResponse, + type AlchemyTokenMetadataResponse, + type OwnerNft as OwnerNftType, + type SearchContractsResult, +} from "@/helpers/alchemy/response-processing"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import type { - SearchContractsResult, - TokenMetadataParams, -} from "@/services/alchemy/types"; import type { ContractOverview, Suggestion, @@ -21,6 +30,8 @@ import type { TokenMetadata, } from "@/types/nft"; +export type OwnerNft = OwnerNftType; + const SUGGESTION_TTL = 60_000; const CONTRACT_TTL = 5 * 60_000; const TOKEN_TTL = 60_000; @@ -34,14 +45,20 @@ const suggestionCache = new Map>(); const contractCache = new Map>(); const tokenCache = new Map>(); -type SerializedTokenMetadata = Omit & { - tokenId: string; -}; - function getBackendAlchemyProxyUrl(path: string): string { return `${publicEnv.API_ENDPOINT}/alchemy-proxy${path}`; } +function isAbortError(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") { + return true; + } + if (error instanceof Error && error.name === "AbortError") { + return true; + } + return false; +} + async function fetchJson( input: RequestInfo, init?: RequestInit @@ -53,16 +70,6 @@ async function fetchJson( return (await response.json()) as T; } -function isAbortError(error: unknown): boolean { - if (error instanceof DOMException && error.name === "AbortError") { - return true; - } - if (error instanceof Error && error.name === "AbortError") { - return true; - } - return false; -} - async function fetchJsonWithFailover( primaryUrl: string, backendPath: string, @@ -82,6 +89,14 @@ async function fetchJsonWithFailover( } } +type TokenMetadataParams = { + readonly address?: `0x${string}`; + readonly tokenIds?: readonly string[]; + readonly tokens?: readonly { contract: string; tokenId: string }[]; + readonly chain?: SupportedChain; + readonly signal?: AbortSignal; +}; + async function fetchCollectionsFromApi( params: UseCollectionSearchParams & { readonly signal?: AbortSignal } ): Promise { @@ -89,13 +104,15 @@ async function fetchCollectionsFromApi( const search = new URLSearchParams(); search.set("query", query); search.set("chain", chain); - search.set("hideSpam", hideSpam ? "1" : "0"); const queryString = search.toString(); - return fetchJsonWithFailover( + + const payload = await fetchJsonWithFailover( `/api/alchemy/collections?${queryString}`, `/collections?${queryString}`, { signal } ); + + return processSearchResponse(payload, hideSpam); } async function fetchContractOverviewFromApi( @@ -105,15 +122,29 @@ async function fetchContractOverviewFromApi( if (!address) { return null; } + + const checksum = normaliseAddress(address); + if (!checksum) { + return null; + } + const search = new URLSearchParams(); search.set("address", address); search.set("chain", chain); const queryString = search.toString(); - return fetchJsonWithFailover( - `/api/alchemy/contract?${queryString}`, - `/contract?${queryString}`, - { signal } - ); + + const payload = await fetchJsonWithFailover< + (AlchemyContractMetadataResponse & { _checksum?: string }) | null + >(`/api/alchemy/contract?${queryString}`, `/contract?${queryString}`, { + signal, + }); + + if (!payload) { + return null; + } + + const checksumFromResponse = (payload._checksum ?? checksum) as `0x${string}`; + return processContractMetadataResponse(payload, checksumFromResponse); } async function fetchTokenMetadataFromApi( @@ -131,15 +162,14 @@ async function fetchTokenMetadataFromApi( body, signal: params.signal, }; - const payload = await fetchJsonWithFailover( + + const payload = await fetchJsonWithFailover( "/api/alchemy/token-metadata", "/token-metadata", init ); - return payload.map((entry) => ({ - ...entry, - tokenId: BigInt(entry.tokenId), - })); + + return processTokenMetadataResponse(payload); } function gcExpired(map: Map>, now = Date.now()): void { @@ -406,23 +436,18 @@ export async function fetchOwnerNfts( contract: string, owner: string, signal?: AbortSignal -): Promise< - { - tokenId: string; - tokenType: string; - name: string | null; - tokenUri: string | null; - image: unknown; - }[] -> { +): Promise { const search = new URLSearchParams(); search.set("chainId", String(chainId)); search.set("contract", contract); search.set("owner", owner); const queryString = search.toString(); - return fetchJsonWithFailover( + + const payload = await fetchJsonWithFailover( `/api/alchemy/owner-nfts?${queryString}`, `/owner-nfts?${queryString}`, { signal } ); + + return processOwnerNftsResponse(payload.ownedNfts ?? []); } From aecf876141e160bda9e5b014a1663970e2faa6a4 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Tue, 16 Dec 2025 10:52:45 +0200 Subject: [PATCH 07/15] WIP Signed-off-by: prxt6529 --- app/api/alchemy/owner-nfts/route.ts | 7 +++++-- app/api/alchemy/token-metadata/route.ts | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/app/api/alchemy/owner-nfts/route.ts b/app/api/alchemy/owner-nfts/route.ts index 036e85d11d..973f467013 100644 --- a/app/api/alchemy/owner-nfts/route.ts +++ b/app/api/alchemy/owner-nfts/route.ts @@ -35,10 +35,13 @@ export async function GET(request: NextRequest) { try { const apiKey = getAlchemyApiKey(); const network = resolveNetworkByChainId(chainId); - let url = `https://${network}.g.alchemy.com/nft/v3/${apiKey}/getNFTsForOwner?owner=${owner}&contractAddresses[]=${contract}`; + const params = new URLSearchParams(); + params.set("owner", owner); + params.append("contractAddresses[]", contract); if (pageKey) { - url += `&pageKey=${pageKey}`; + params.set("pageKey", pageKey); } + const url = `https://${network}.g.alchemy.com/nft/v3/${apiKey}/getNFTsForOwner?${params.toString()}`; const response = await fetch(url, { headers: { accept: "application/json" }, diff --git a/app/api/alchemy/token-metadata/route.ts b/app/api/alchemy/token-metadata/route.ts index 9d6514927b..07e7c25424 100644 --- a/app/api/alchemy/token-metadata/route.ts +++ b/app/api/alchemy/token-metadata/route.ts @@ -39,10 +39,18 @@ export async function POST(request: NextRequest) { let tokensToFetch: { contractAddress: string; tokenId: string }[] = []; if (tokens && tokens.length > 0) { - tokensToFetch = tokens.map((t) => ({ - contractAddress: t.contract, - tokenId: t.tokenId, - })); + tokensToFetch = tokens + .filter((t) => isValidEthAddress(t.contract)) + .map((t) => ({ + contractAddress: normaliseAddress(t.contract) ?? t.contract, + tokenId: t.tokenId, + })); + if (tokensToFetch.length === 0) { + return NextResponse.json( + { error: "No valid contract addresses provided" }, + { status: 400, headers: NO_STORE_HEADERS } + ); + } } else if (address && Array.isArray(tokenIds) && tokenIds.length > 0) { if (!isValidEthAddress(address)) { return NextResponse.json( From c095aa61a9e9de976a7b400404077cbf07dc7a47 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Tue, 16 Dec 2025 10:55:32 +0200 Subject: [PATCH 08/15] Code dupe Signed-off-by: prxt6529 --- helpers/alchemy/response-processing.ts | 163 +++++-------------------- 1 file changed, 33 insertions(+), 130 deletions(-) diff --git a/helpers/alchemy/response-processing.ts b/helpers/alchemy/response-processing.ts index f6f3683253..4df2bb78a0 100644 --- a/helpers/alchemy/response-processing.ts +++ b/helpers/alchemy/response-processing.ts @@ -2,127 +2,35 @@ import { getAddress } from "viem"; import { isValidEthAddress } from "@/helpers/Helpers"; import type { - ContractOverview, - Suggestion, - TokenMetadata, -} from "@/types/nft"; + AlchemyContractMetadata, + AlchemyContractMetadataResponse, + AlchemyContractResult, + AlchemyGetNftsForOwnerResponse, + AlchemyNftMedia, + AlchemyOpenSeaMetadata, + AlchemyOwnedNft, + AlchemySearchResponse, + AlchemyTokenMetadataEntry, + AlchemyTokenMetadataResponse, +} from "@/services/alchemy/types"; +import type { ContractOverview, Suggestion, TokenMetadata } from "@/types/nft"; -export type AlchemyOpenSeaMetadata = { - imageUrl?: string | null; - bannerImageUrl?: string | null; - collectionName?: string | null; - safelistRequestStatus?: string | null; - floorPrice?: number | { eth?: number | string | null } | null; - description?: string | null; -}; - -export type AlchemyContractMetadata = { - address?: string | null; - name?: string | null; - symbol?: string | null; - tokenType?: string | null; - totalSupply?: string | null; - image?: { cachedUrl?: string | null; thumbnailUrl?: string | null } | null; - bannerImageUrl?: string | null; - description?: string | null; - contractDeployer?: string | null; - deployedBlockNumber?: number | null; - openSeaMetadata?: AlchemyOpenSeaMetadata; - openseaMetadata?: AlchemyOpenSeaMetadata; - openSea?: AlchemyOpenSeaMetadata; - opensea?: AlchemyOpenSeaMetadata; - isSpam?: boolean; - spamInfo?: { isSpam?: boolean }; -}; - -export type AlchemyContractResult = { - address?: string; - contractAddress?: string; - contractDeployer?: string | null; - isSpam?: boolean; - spamInfo?: { isSpam?: boolean }; - spamClassifications?: string[] | null; - contractMetadata?: AlchemyContractMetadata; - openSeaMetadata?: AlchemyOpenSeaMetadata; - openseaMetadata?: AlchemyOpenSeaMetadata; -} & AlchemyContractMetadata; - -export type AlchemySearchResponse = { - contracts?: AlchemyContractResult[]; - pageKey?: string; -}; - -export type AlchemyContractMetadataResponse = AlchemyContractMetadata & { - contractMetadata?: AlchemyContractMetadata | null; - openSeaMetadata?: AlchemyOpenSeaMetadata | null; - openseaMetadata?: AlchemyOpenSeaMetadata | null; - address?: string | null; - contractAddress?: string | null; - isSpam?: boolean; +export type { + AlchemyContractMetadataResponse, + AlchemyGetNftsForOwnerResponse, + AlchemySearchResponse, + AlchemyTokenMetadataResponse, }; -export type AlchemyNftMedia = { - cachedUrl?: string | null; - thumbnailUrl?: string | null; - originalUrl?: string | null; - pngUrl?: string | null; - gateway?: string | null; - raw?: string | null; - contentType?: string | null; - size?: number | null; -}; - -export type AlchemyNftMetadata = { - image?: string | null; - name?: string | null; - description?: string | null; - [key: string]: unknown; -}; - -export type AlchemyTokenMetadataEntry = { - contract?: (AlchemyContractMetadata & { spamClassifications?: string[] | null }) | null; - tokenId?: string; - tokenType?: string | null; - title?: string | null; - name?: string | null; - description?: string | null; - tokenUri?: string | null; - image?: AlchemyNftMedia | null; - animation?: AlchemyNftMedia | null; - media?: AlchemyNftMedia[] | null; - metadata?: AlchemyNftMetadata | null; - raw?: { - tokenUri?: string | null; - metadata?: AlchemyNftMetadata | null; - error?: string | null; - } | null; - collection?: { name?: string | null } | null; - isSpam?: boolean; - spamInfo?: { isSpam?: boolean } | null; -}; - -export type AlchemyTokenMetadataResponse = { - tokens?: AlchemyTokenMetadataEntry[]; - nfts?: AlchemyTokenMetadataEntry[]; -}; - -export type AlchemyOwnedNft = AlchemyTokenMetadataEntry & { - balance?: string | null; -}; - -export type AlchemyGetNftsForOwnerResponse = { - ownedNfts: AlchemyOwnedNft[]; - pageKey?: string; - totalCount?: number; - error?: { code?: number; message?: string }; -}; - -type OpenSeaMetadataSource = { - openSeaMetadata?: AlchemyOpenSeaMetadata | null; - openseaMetadata?: AlchemyOpenSeaMetadata | null; - openSea?: AlchemyOpenSeaMetadata | null; - opensea?: AlchemyOpenSeaMetadata | null; -} | null | undefined; +type OpenSeaMetadataSource = + | { + openSeaMetadata?: AlchemyOpenSeaMetadata | null; + openseaMetadata?: AlchemyOpenSeaMetadata | null; + openSea?: AlchemyOpenSeaMetadata | null; + opensea?: AlchemyOpenSeaMetadata | null; + } + | null + | undefined; export function normaliseAddress( address?: string | null @@ -177,8 +85,8 @@ function pickImage(source?: { return source.image.thumbnailUrl; } if (source.media && source.media.length > 0) { - const mediaItem = source.media.find((item) => item?.thumbnailUrl) ?? - source.media[0]; + const mediaItem = + source.media.find((item) => item?.thumbnailUrl) ?? source.media[0]; if (mediaItem?.thumbnailUrl) { return mediaItem.thumbnailUrl; } @@ -203,8 +111,8 @@ function pickThumbnail(source?: { return source.image.cachedUrl; } if (source.media && source.media.length > 0) { - const mediaItem = source.media.find((item) => item?.thumbnailUrl) ?? - source.media[0]; + const mediaItem = + source.media.find((item) => item?.thumbnailUrl) ?? source.media[0]; if (mediaItem?.thumbnailUrl) { return mediaItem.thumbnailUrl; } @@ -212,9 +120,7 @@ function pickThumbnail(source?: { return null; } -function toSafelist( - status: string | null | undefined -): Suggestion["safelist"] { +function toSafelist(status: string | null | undefined): Suggestion["safelist"] { if (!status) { return undefined; } @@ -331,8 +237,7 @@ export function processContractMetadataResponse( if (!payload) { return null; } - const baseMeta: AlchemyContractMetadata = - payload.contractMetadata ?? payload; + const baseMeta: AlchemyContractMetadata = payload.contractMetadata ?? payload; const openSeaMetadata = resolveOpenSeaMetadata( payload, payload.contractMetadata, @@ -344,8 +249,7 @@ export function processContractMetadataResponse( address: checksum, contractAddress: checksum, openSeaMetadata, - isSpam: - payload.isSpam ?? baseMeta.isSpam ?? baseMeta.spamInfo?.isSpam, + isSpam: payload.isSpam ?? baseMeta.isSpam ?? baseMeta.spamInfo?.isSpam, }; const suggestion = extractContract(contract); if (!suggestion) { @@ -432,4 +336,3 @@ export function processTokenMetadataResponse( } return results; } - From 4ae931ba406ee03814ae520033eb3e2b4b31835d Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Tue, 16 Dec 2025 11:03:03 +0200 Subject: [PATCH 09/15] WIP Signed-off-by: prxt6529 --- helpers/alchemy/response-processing.ts | 199 +------------------------ hooks/useAlchemyNftQueries.ts | 10 +- 2 files changed, 13 insertions(+), 196 deletions(-) diff --git a/helpers/alchemy/response-processing.ts b/helpers/alchemy/response-processing.ts index 4df2bb78a0..901d5d47ab 100644 --- a/helpers/alchemy/response-processing.ts +++ b/helpers/alchemy/response-processing.ts @@ -1,207 +1,22 @@ -import { getAddress } from "viem"; - -import { isValidEthAddress } from "@/helpers/Helpers"; import type { AlchemyContractMetadata, AlchemyContractMetadataResponse, AlchemyContractResult, - AlchemyGetNftsForOwnerResponse, AlchemyNftMedia, - AlchemyOpenSeaMetadata, AlchemyOwnedNft, AlchemySearchResponse, AlchemyTokenMetadataEntry, AlchemyTokenMetadataResponse, } from "@/services/alchemy/types"; +import { + extractContract, + normaliseAddress, + pickThumbnail, + resolveOpenSeaMetadata, +} from "@/services/alchemy/utils"; import type { ContractOverview, Suggestion, TokenMetadata } from "@/types/nft"; -export type { - AlchemyContractMetadataResponse, - AlchemyGetNftsForOwnerResponse, - AlchemySearchResponse, - AlchemyTokenMetadataResponse, -}; - -type OpenSeaMetadataSource = - | { - openSeaMetadata?: AlchemyOpenSeaMetadata | null; - openseaMetadata?: AlchemyOpenSeaMetadata | null; - openSea?: AlchemyOpenSeaMetadata | null; - opensea?: AlchemyOpenSeaMetadata | null; - } - | null - | undefined; - -export function normaliseAddress( - address?: string | null -): `0x${string}` | null { - if (!address) { - return null; - } - if (!isValidEthAddress(address)) { - return null; - } - try { - return getAddress(address); - } catch { - return address as `0x${string}`; - } -} - -function resolveOpenSeaMetadata( - ...sources: OpenSeaMetadataSource[] -): AlchemyOpenSeaMetadata | undefined { - for (const source of sources) { - if (!source) { - continue; - } - const metadata = - source.openSeaMetadata ?? - source.openseaMetadata ?? - source.openSea ?? - source.opensea; - if (metadata) { - return metadata; - } - } - return undefined; -} - -function pickImage(source?: { - image?: { cachedUrl?: string | null; thumbnailUrl?: string | null } | null; - imageUrl?: string | null; - media?: { thumbnailUrl?: string | null; gateway?: string | null }[] | null; -}): string | null { - if (!source) { - return null; - } - if (source.imageUrl) { - return source.imageUrl; - } - if (source.image?.cachedUrl) { - return source.image.cachedUrl; - } - if (source.image?.thumbnailUrl) { - return source.image.thumbnailUrl; - } - if (source.media && source.media.length > 0) { - const mediaItem = - source.media.find((item) => item?.thumbnailUrl) ?? source.media[0]; - if (mediaItem?.thumbnailUrl) { - return mediaItem.thumbnailUrl; - } - if (mediaItem?.gateway) { - return mediaItem.gateway; - } - } - return null; -} - -function pickThumbnail(source?: { - image?: { thumbnailUrl?: string | null; cachedUrl?: string | null } | null; - media?: { thumbnailUrl?: string | null }[] | null; -}): string | null { - if (!source) { - return null; - } - if (source.image?.thumbnailUrl) { - return source.image.thumbnailUrl; - } - if (source.image?.cachedUrl) { - return source.image.cachedUrl; - } - if (source.media && source.media.length > 0) { - const mediaItem = - source.media.find((item) => item?.thumbnailUrl) ?? source.media[0]; - if (mediaItem?.thumbnailUrl) { - return mediaItem.thumbnailUrl; - } - } - return null; -} - -function toSafelist(status: string | null | undefined): Suggestion["safelist"] { - if (!status) { - return undefined; - } - if ( - status === "verified" || - status === "approved" || - status === "requested" || - status === "not_requested" - ) { - return status; - } - return undefined; -} - -function parseFloorPrice( - meta: AlchemyOpenSeaMetadata | undefined -): number | null { - if (!meta) { - return null; - } - const { floorPrice } = meta; - if (typeof floorPrice === "number") { - return floorPrice; - } - if (floorPrice && typeof floorPrice === "object") { - const candidate = floorPrice.eth; - if (typeof candidate === "number") { - return candidate; - } - if (typeof candidate === "string") { - const parsed = Number(candidate); - return Number.isFinite(parsed) ? parsed : null; - } - } - return null; -} - -function extractContract(contract: AlchemyContractResult): Suggestion | null { - const baseMeta = contract.contractMetadata ?? contract; - const openSea = resolveOpenSeaMetadata(contract, baseMeta); - const address = - normaliseAddress(contract.address ?? contract.contractAddress) ?? - normaliseAddress(baseMeta.address); - if (!address) { - return null; - } - const name = baseMeta.name ?? openSea?.collectionName ?? undefined; - const totalSupply = baseMeta.totalSupply ?? undefined; - const tokenType = baseMeta.tokenType?.toUpperCase() as - | "ERC721" - | "ERC1155" - | undefined; - const imageUrl = pickImage({ - imageUrl: openSea?.imageUrl ?? undefined, - image: baseMeta.image, - }); - const isSpam = - contract.isSpam ?? - contract.spamInfo?.isSpam ?? - baseMeta.isSpam ?? - baseMeta.spamInfo?.isSpam ?? - false; - const safelist = toSafelist(openSea?.safelistRequestStatus ?? null); - const floorPriceEth = parseFloorPrice(openSea ?? undefined); - const deployer = normaliseAddress( - contract.contractDeployer ?? baseMeta.contractDeployer ?? null - ); - - return { - address, - name: name ?? undefined, - symbol: baseMeta.symbol ?? undefined, - tokenType, - totalSupply: totalSupply ?? undefined, - floorPriceEth, - imageUrl: imageUrl ?? null, - isSpam: isSpam ?? false, - safelist, - deployer: deployer ?? null, - }; -} +export { normaliseAddress }; export type SearchContractsResult = { items: Suggestion[]; diff --git a/hooks/useAlchemyNftQueries.ts b/hooks/useAlchemyNftQueries.ts index 4bb3e163b3..d9724965aa 100644 --- a/hooks/useAlchemyNftQueries.ts +++ b/hooks/useAlchemyNftQueries.ts @@ -15,14 +15,16 @@ import { processOwnerNftsResponse, processSearchResponse, processTokenMetadataResponse, - type AlchemyContractMetadataResponse, - type AlchemyGetNftsForOwnerResponse, - type AlchemySearchResponse, - type AlchemyTokenMetadataResponse, type OwnerNft as OwnerNftType, type SearchContractsResult, } from "@/helpers/alchemy/response-processing"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import type { + AlchemyContractMetadataResponse, + AlchemyGetNftsForOwnerResponse, + AlchemySearchResponse, + AlchemyTokenMetadataResponse, +} from "@/services/alchemy/types"; import type { ContractOverview, Suggestion, From d34da8b7790e8727b2724467a44503279ec28e91 Mon Sep 17 00:00:00 2001 From: prxt6529 Date: Tue, 16 Dec 2025 11:35:07 +0200 Subject: [PATCH 10/15] WIP Signed-off-by: prxt6529 --- .../mint/NextGenMintBurnWidget.test.tsx | 133 +++++++++------ __tests__/services/alchemy-api.test.ts | 4 + app/api/alchemy/contract/route.ts | 2 +- app/api/alchemy/token-metadata/route.ts | 2 +- .../mint/NextGenMintBurnWidget.tsx | 3 +- helpers/alchemy/response-processing.ts | 153 ------------------ hooks/useAlchemyNftQueries.ts | 22 ++- jest.setup.js | 77 ++++----- services/alchemy/collections.ts | 68 ++------ services/alchemy/owner-nfts.ts | 27 ++-- services/alchemy/tokens.ts | 63 +------- services/alchemy/types.ts | 17 +- services/alchemy/utils.ts | 124 +++++++++++++- 13 files changed, 311 insertions(+), 384 deletions(-) delete mode 100644 helpers/alchemy/response-processing.ts diff --git a/__tests__/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.test.tsx b/__tests__/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.test.tsx index f923950eb2..bba606347c 100644 --- a/__tests__/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.test.tsx +++ b/__tests__/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget.test.tsx @@ -1,11 +1,13 @@ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import NextGenMintBurnWidget from '@/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget'; -import { Status } from '@/components/nextGen/nextgen_entities'; -import { NEXTGEN_CHAIN_ID, NEXTGEN_CORE } from '@/components/nextGen/nextgen_contracts'; - -jest.mock('react-bootstrap', () => { - const React = require('react'); +import NextGenMintBurnWidget from "@/components/nextGen/collections/collectionParts/mint/NextGenMintBurnWidget"; +import { + NEXTGEN_CHAIN_ID, + NEXTGEN_CORE, +} from "@/components/nextGen/nextgen_contracts"; +import { Status } from "@/components/nextGen/nextgen_entities"; +import { render, screen, waitFor } from "@testing-library/react"; + +jest.mock("react-bootstrap", () => { + const React = require("react"); const Form = (p: any) =>
{p.children}
; Form.Group = (p: any) =>
; Form.Label = (p: any) =>