From 31b657ac7bf60e99672cf65b37de6a2a0489c36b Mon Sep 17 00:00:00 2001 From: Jango Date: Mon, 17 Aug 2026 19:35:21 -0300 Subject: [PATCH] Address eth.sucks the way it serves, and collapse the NFT gateway chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway was never gone — the URL shape was wrong. It serves the subdomain form (`.eth.sucks/`), which is what the code's own comment described, while ethSucksGatewayUrl emitted the path form that answers 410 Gone. Verified live: subdomain returns the 3.4MB tier gif in 1.0s, path returns 410. NFT tier metadata had three copy-pasted try/catch blocks that all preferred URL shapes which had stopped serving, and rewrote ipfs.io media onto cid.v2ex.pro, which no longer resolves. That is now one walk over gateways that answer, in a module small enough to test, with retired hosts re-pointed at a live one. CSP and next/image only allowed the path form, so the subdomain form needed adding to img-src, media-src, connect-src and remotePatterns. Co-Authored-By: Claude Opus 5 (1M context) --- next.config.js | 10 ++ .../__tests__/nftTierMedia.test.ts | 63 +++++++++ .../v4v5/contexts/V4V5NftRewards/tierMedia.ts | 45 +++++++ .../contexts/V4V5NftRewards/useNftRewards.ts | 122 +++--------------- src/utils/__tests__/ipfs.test.ts | 23 ++-- src/utils/ipfs.ts | 19 +-- 6 files changed, 158 insertions(+), 124 deletions(-) create mode 100644 src/packages/v4v5/contexts/V4V5NftRewards/__tests__/nftTierMedia.test.ts create mode 100644 src/packages/v4v5/contexts/V4V5NftRewards/tierMedia.ts diff --git a/next.config.js b/next.config.js index b9c4ed8490..ad3b5f2a1b 100644 --- a/next.config.js +++ b/next.config.js @@ -27,6 +27,9 @@ const INFURA_IPFS_URLS = [ const ETH_SUCKS_URLS = [ 'https://ipfs.banny.eth.sucks', 'https://ipfs.banny.eth.sucks/*', + // The gateway serves the subdomain form (`.eth.sucks`), so the wildcard + // is what actually covers reads; the path form above is kept for old links. + 'https://*.eth.sucks', ] const V2EX_URLS = [ @@ -381,6 +384,13 @@ const nextConfig = removeImports({ port: '', pathname: '/ipfs/**', }, + { + // The subdomain form the gateway actually serves: `.eth.sucks/`. + protocol: 'https', + hostname: '*.eth.sucks', + port: '', + pathname: '/**', + }, { protocol: 'https', hostname: 'cid.v2ex.pro', diff --git a/src/packages/v4v5/contexts/V4V5NftRewards/__tests__/nftTierMedia.test.ts b/src/packages/v4v5/contexts/V4V5NftRewards/__tests__/nftTierMedia.test.ts new file mode 100644 index 0000000000..b35ba7b685 --- /dev/null +++ b/src/packages/v4v5/contexts/V4V5NftRewards/__tests__/nftTierMedia.test.ts @@ -0,0 +1,63 @@ +import axios from 'axios' +import { ethSucksGatewayUrl, pinataGatewayUrl } from 'utils/ipfs' + +import { fetchTierMetadata, liveMediaUrl } from '../tierMedia' + +jest.mock('axios') + +const mockedGet = axios.get as jest.Mock +const CID = 'QmNLei78zWmzUdbeRB3CiUfAizWUrbeeZh5K1rhAQKCh51' + +describe('NFT tier media', () => { + beforeEach(() => { + mockedGet.mockReset() + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + jest.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => jest.restoreAllMocks()) + + it('walks to the next gateway when one fails', async () => { + mockedGet + .mockRejectedValueOnce(new Error('410')) + .mockResolvedValueOnce({ data: { name: 'Tier 1' } }) + + await expect(fetchTierMetadata(CID)).resolves.toEqual({ name: 'Tier 1' }) + expect(mockedGet).toHaveBeenCalledTimes(2) + expect(mockedGet.mock.calls[0][0]).toBe(ethSucksGatewayUrl(CID)) + }) + + it('falls all the way through to Pinata', async () => { + mockedGet + .mockRejectedValueOnce(new Error('down')) + .mockRejectedValueOnce(new Error('down')) + .mockResolvedValueOnce({ data: { name: 'Tier 1' } }) + + await fetchTierMetadata(CID) + expect(mockedGet.mock.calls[2][0]).toBe(pinataGatewayUrl(CID)) + }) + + it('throws once every gateway has been tried', async () => { + mockedGet.mockRejectedValue(new Error('down')) + await expect(fetchTierMetadata(CID)).rejects.toThrow('down') + expect(mockedGet).toHaveBeenCalledTimes(3) + }) + + it.each([ + `https://jbx.mypinata.cloud/ipfs/${CID}`, + `https://jbm.infura-ipfs.io/ipfs/${CID}`, + `https://cid.v2ex.pro/ipfs/${CID}`, + `https://ipfs.banny.eth.sucks/ipfs/${CID}`, + ])('re-points media stored on a retired gateway (%s)', image => { + expect(liveMediaUrl(image)).toBe(ethSucksGatewayUrl(CID)) + }) + + it('leaves media on a live host alone', () => { + expect(liveMediaUrl('https://example.com/tier.png')).toBe( + 'https://example.com/tier.png', + ) + expect(liveMediaUrl('data:image/svg+xml;base64,abc')).toBe( + 'data:image/svg+xml;base64,abc', + ) + }) +}) diff --git a/src/packages/v4v5/contexts/V4V5NftRewards/tierMedia.ts b/src/packages/v4v5/contexts/V4V5NftRewards/tierMedia.ts new file mode 100644 index 0000000000..09758a6a1e --- /dev/null +++ b/src/packages/v4v5/contexts/V4V5NftRewards/tierMedia.ts @@ -0,0 +1,45 @@ +import axios from 'axios' +import { IPFSNftRewardTier } from 'models/nftRewards' +import { + cidFromUrl, + ethSucksGatewayUrl, + ipfsGatewayUrl, + pinataGatewayUrl, +} from 'utils/ipfs' + +/** + * The gateways a tier's metadata and media are read from, in order. + * + * eth.sucks first (fast, and it serves the subdomain form), then the configured + * gateway, then Pinata's public one. This used to be three copy-pasted try/catch + * blocks that all preferred URL shapes which had stopped serving — the eth.sucks + * path form (410 Gone) and cid.v2ex.pro (no longer resolves). + */ +const TIER_GATEWAY_URLS = [ethSucksGatewayUrl, ipfsGatewayUrl, pinataGatewayUrl] + +/** Legacy hosts baked into old tier metadata, whose CIDs we re-point at a live gateway. */ +const RETIRED_MEDIA_HOSTS = + /^https?:\/\/(jbx\.mypinata\.cloud|jbm\.infura-ipfs\.io|cid\.v2ex\.pro|ipfs\.banny\.eth\.sucks|ipfs\.io)\// + +export async function fetchTierMetadata(tierCid: string): Promise { + let lastError: unknown + for (const gatewayUrl of TIER_GATEWAY_URLS) { + try { + const response = await axios.get(gatewayUrl(tierCid)) + return response.data as IPFSNftRewardTier + } catch (error) { + lastError = error + console.warn(`IPFS gateway failed for CID ${tierCid}`, gatewayUrl(tierCid)) + } + } + console.error(`All IPFS gateways failed for CID ${tierCid}`) + throw lastError +} + +/** Re-point media stored on a gateway that has stopped serving. */ +export function liveMediaUrl(image: string): string { + if (!RETIRED_MEDIA_HOSTS.test(image)) return image + const cid = cidFromUrl(image) + return cid ? ethSucksGatewayUrl(cid) : image +} + diff --git a/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts b/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts index 0a57cbe401..4750eccddb 100644 --- a/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts +++ b/src/packages/v4v5/contexts/V4V5NftRewards/useNftRewards.ts @@ -2,14 +2,7 @@ import { UseQueryResult, useQuery } from '@tanstack/react-query' import { formatEther, jb721TiersHookStoreAbi } from 'juice-sdk-core' import { readContract } from 'wagmi/actions' import { IPFSNftRewardTier, NftRewardTier } from 'models/nftRewards' -import { - cidFromUrl, - decodeEncodedIpfsUri, - ethSucksGatewayUrl, - ipfsGatewayUrl, - pinataGatewayUrl, - v2exGatewayUrl -} from 'utils/ipfs' +import { decodeEncodedIpfsUri } from 'utils/ipfs' import axios from 'axios' import { JBChainId } from 'juice-sdk-react' @@ -17,112 +10,31 @@ import { withHttps } from 'utils/externalLink' import { zeroAddress } from 'viem' import { useConfig } from 'wagmi' import { JB721TierV4 } from './V4V5NftRewardsProvider' +import { fetchTierMetadata, liveMediaUrl } from './tierMedia' const NFT_PAGE_SIZE = 100n -// fetchRewardTierMetadata takes three steps to retrieve and process metadata: -// 1. Attempt to fetch metadata from the primary eth.sucks gateway. -// 2. If the primary gateway fails, fallback to the Infura (jbm.infura-ipfs) gateway, and then to the Pinata gateway. -// 3. Process the retrieved metadata (e.g., handle image URLs, format data) and return the reward tier details. -async function fetchRewardTierMetadata({ - tier, - perChainSupply -}: { +async function fetchRewardTierMetadata({ + tier, + perChainSupply, +}: { tier: JB721TierV4 perChainSupply?: { chainId: number; remainingSupply: number }[] }) { const tierCid = decodeEncodedIpfsUri(tier.encodedIPFSUri) - const primaryUrl = ethSucksGatewayUrl(tierCid) // Use eth.sucks as primary - - try { - // First try the eth.sucks gateway (primary) - const response = await axios.get(primaryUrl) - const tierMetadata: IPFSNftRewardTier = response.data - - const maxSupply = tier.initialSupply - - // Some projects have image links hard-coded to the old IPFS gateway. - const pinataRegex = /^(https?:\/\/jbx\.mypinata\.cloud)/ - const infuraRegex = /^(https?:\/\/jbm\.infura-ipfs\.io\/ipfs\/)/ - const ipfsIoRegex = /^(https?:\/\/ipfs\.io\/ipfs\/)/ - - if (tierMetadata?.image && pinataRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Use eth.sucks for images too - } else if (tierMetadata?.image && infuraRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Convert Infura URLs to eth.sucks - } else if (tierMetadata?.image && ipfsIoRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = v2exGatewayUrl(imageUrlCid) // Convert ipfs.io URLs to v2ex.pro gateway - } - - const rawContributionFloor = tier.price - - return processMetadata(tier, tierMetadata, maxSupply, rawContributionFloor, perChainSupply) - } catch (error) { - console.warn(`eth.sucks gateway failed for CID ${tierCid}, trying Infura fallback`) - - try { - // Try the jbm-Infura gateway as first fallback - const infuraUrl = ipfsGatewayUrl(tierCid) - const response = await axios.get(infuraUrl) - const tierMetadata: IPFSNftRewardTier = response.data - const maxSupply = tier.initialSupply - const rawContributionFloor = tier.price - - // Handle Pinata and Infura image links - const pinataRegex = /^(https?:\/\/jbx\.mypinata\.cloud)/ - const infuraRegex = /^(https?:\/\/jbm\.infura-ipfs\.io\/ipfs\/)/ - const ipfsIoRegex = /^(https?:\/\/ipfs\.io\/ipfs\/)/ - - if (tierMetadata?.image && pinataRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Still prefer eth.sucks for images - } else if (tierMetadata?.image && infuraRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Convert Infura URLs to eth.sucks - } else if (tierMetadata?.image && ipfsIoRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = v2exGatewayUrl(imageUrlCid) // Convert ipfs.io URLs to v2ex.pro gateway - } - - return processMetadata(tier, tierMetadata, maxSupply, rawContributionFloor, perChainSupply) - } catch (infuraError) { - console.warn(`Infura gateway failed for CID ${tierCid}, trying Pinata fallback`) - - try { - // Try the Pinata gateway as final fallback - const pinataUrl = pinataGatewayUrl(tierCid) - const response = await axios.get(pinataUrl) - const tierMetadata: IPFSNftRewardTier = response.data - const maxSupply = tier.initialSupply - const rawContributionFloor = tier.price + const tierMetadata = await fetchTierMetadata(tierCid) - // Handle Pinata and Infura image links - const pinataRegex = /^(https?:\/\/jbx\.mypinata\.cloud)/ - const infuraRegex = /^(https?:\/\/jbm\.infura-ipfs\.io\/ipfs\/)/ - const ipfsIoRegex = /^(https?:\/\/ipfs\.io\/ipfs\/)/ - - if (tierMetadata?.image && pinataRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Still prefer eth.sucks for images - } else if (tierMetadata?.image && infuraRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = ethSucksGatewayUrl(imageUrlCid) // Convert Infura URLs to eth.sucks - } else if (tierMetadata?.image && ipfsIoRegex.test(tierMetadata.image)) { - const imageUrlCid = cidFromUrl(tierMetadata.image) - tierMetadata.image = v2exGatewayUrl(imageUrlCid) // Convert ipfs.io URLs to v2ex.pro gateway - } - - return processMetadata(tier, tierMetadata, maxSupply, rawContributionFloor, perChainSupply) - } catch (pinataError) { - // If all gateways fail, rethrow the original error - console.error(`All IPFS gateways failed for CID ${tierCid}`) - throw error - } - } + if (tierMetadata.image) { + tierMetadata.image = liveMediaUrl(tierMetadata.image) } + + return processMetadata( + tier, + tierMetadata, + tier.initialSupply, + tier.price, + perChainSupply, + ) } // Helper function to process metadata and return the reward tier diff --git a/src/utils/__tests__/ipfs.test.ts b/src/utils/__tests__/ipfs.test.ts index 5935f01a94..feed95a2ce 100644 --- a/src/utils/__tests__/ipfs.test.ts +++ b/src/utils/__tests__/ipfs.test.ts @@ -1,8 +1,8 @@ -import { OPEN_IPFS_GATEWAY_HOSTNAME } from 'constants/ipfs' - import { + convertToV1CID, decodeEncodedIpfsUri, encodeIpfsUri, + ethSucksGatewayUrl, ipfsUriToGatewayUrl, pinataToGatewayUrl, } from '../ipfs' @@ -10,18 +10,21 @@ import { const CID = 'QmNLei78zWmzUdbeRB3CiUfAizWUrbeeZh5K1rhAQKCh51' describe('ipfs gateway urls', () => { - // Both of these used to resolve to gateways that stopped serving: the - // dedicated Pinata one is NXDOMAIN and eth.sucks answers 410, so every image - // routed through them rendered broken. - it('routes ipfs:// uris at the read gateway', () => { - expect(ipfsUriToGatewayUrl(`ipfs://${CID}`)).toBe( - `https://${OPEN_IPFS_GATEWAY_HOSTNAME}/ipfs/${CID}`, - ) + // The gateway serves the subdomain form. Its path form + // (`ipfs.banny.eth.sucks/ipfs/`) answers 410 Gone, which is what left + // every project logo and NFT tier image broken. + it('addresses eth.sucks by subdomain with a v1 CID', () => { + expect(ethSucksGatewayUrl(CID)).toBe(`https://${convertToV1CID(CID)}.eth.sucks/`) + expect(ethSucksGatewayUrl(CID)).not.toContain('/ipfs/') + }) + + it('routes ipfs:// uris there', () => { + expect(ipfsUriToGatewayUrl(`ipfs://${CID}`)).toBe(ethSucksGatewayUrl(CID)) }) it('rewrites urls on the retired dedicated gateway', () => { expect(pinataToGatewayUrl(`https://jbx.mypinata.cloud/ipfs/${CID}`)).toBe( - `https://${OPEN_IPFS_GATEWAY_HOSTNAME}/ipfs/${CID}`, + ethSucksGatewayUrl(CID), ) }) diff --git a/src/utils/ipfs.ts b/src/utils/ipfs.ts index 2e22c76074..264be5e44d 100644 --- a/src/utils/ipfs.ts +++ b/src/utils/ipfs.ts @@ -18,13 +18,15 @@ export const ipfsGatewayUrl = ( } /** - * Return a URL to ETH Sucks gateway for the given CID. - * Uses the new ipfs.banny.eth.sucks format. + * Return a URL to the eth.sucks gateway for the given CID. + * + * Subdomain form with a v1 CID, which is what that gateway serves — the path form + * (`ipfs.banny.eth.sucks/ipfs/`) answers 410 Gone, so everything routed + * through it rendered broken. */ export const ethSucksGatewayUrl = (cid: string | undefined): string => { if (!cid) return '' - // this can use either v0 or v1 CID according to Livid - return `https://ipfs.banny.eth.sucks/ipfs/${cid}` + return `https://${convertToV1CID(cid)}.eth.sucks/` } /** @@ -86,14 +88,13 @@ export const cidFromIpfsUri = (ipfsUri: string) => ipfsUri.match(IPFS_URL_REGEX)?.[1] /** - * Returns a native IPFS link (`ipfs://`) as an https link on the read gateway. + * Returns a native IPFS link (`ipfs://`) as an https link on a read gateway. */ export function ipfsUriToGatewayUrl(ipfsUri: string): string { if (!isIpfsUri(ipfsUri)) return ipfsUri - // Not eth.sucks: that gateway answers 410 Gone, so every image routed through - // it renders broken. - return ipfsGatewayUrl(cidFromIpfsUri(ipfsUri)) + const cid = cidFromIpfsUri(ipfsUri) + return cid && isIpfsCID(cid) ? ethSucksGatewayUrl(cid) : ipfsGatewayUrl(cid) } /** @@ -112,7 +113,7 @@ export function pinataToGatewayUrl(url: string) { } if (cid && isIpfsCID(cid)) { - return ipfsGatewayUrl(cid) + return ethSucksGatewayUrl(cid) } if (url.startsWith('https://jbx.mypinata.cloud')) {