From d497a56ef7f21bdc5a94428a08d4cc64275643bf Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Wed, 22 Jul 2026 19:24:39 +0200 Subject: [PATCH 1/2] fix(keychain): fall back to collection image when token images fail Torii serves token images only under the zero-padded lowercase contract address, and some collections (e.g. Glitch Bomb) return token-level image bodies that are not decodable, so inventory cards, collection grids and activity/traceability rows rendered placeholders while the asset page recovered via its collection-image fallback. Centralize static image URL construction in torii-url helpers (normalized like cartridge-gg/arcade#300) and apply the asset page's fallback chain (token image -> legacy unpadded URL -> metadata image -> collection image) everywhere: collection hooks, activity feed, traceability cards and purchase views. ThumbnailCollectible now probes a list of candidates and displays the first one that actually decodes. Co-Authored-By: Claude Fable 5 --- packages/keychain/src/components/activity.tsx | 2 +- .../collection/collectible-asset.tsx | 10 +-- .../collection/collectible-purchase.tsx | 15 +++-- .../inventory/collection/collection-asset.tsx | 10 +-- .../collection/collection-purchase.tsx | 12 +++- .../keychain/src/components/provider/data.tsx | 16 ++++- .../keychain/src/helpers/torii-url.test.ts | 65 ++++++++++++++++++- packages/keychain/src/helpers/torii-url.ts | 63 ++++++++++++++++++ packages/keychain/src/hooks/collection.ts | 40 ++++++++---- .../activities/card/collectible-card.tsx | 2 +- .../thumbnails/collectible/collectible.tsx | 54 ++++++++++++++- .../traceabilities/card/collectible-card.tsx | 2 +- 12 files changed, 249 insertions(+), 42 deletions(-) diff --git a/packages/keychain/src/components/activity.tsx b/packages/keychain/src/components/activity.tsx index 54a51e05cf..7768aab63a 100644 --- a/packages/keychain/src/components/activity.tsx +++ b/packages/keychain/src/components/activity.tsx @@ -100,7 +100,7 @@ export function Activity() { collection={props.collection} address={props.address} username={props.username} - image={props.image} + image={props.images ?? props.image} action={props.action} timestamp={props.timestamp * 1000} /> diff --git a/packages/keychain/src/components/inventory/collection/collectible-asset.tsx b/packages/keychain/src/components/inventory/collection/collectible-asset.tsx index 7cec4efbd8..28f3b4cbb6 100644 --- a/packages/keychain/src/components/inventory/collection/collectible-asset.tsx +++ b/packages/keychain/src/components/inventory/collection/collectible-asset.tsx @@ -302,11 +302,11 @@ export function CollectibleAsset() { username={props.username || ""} timestamp={props.timestamp} category={props.category} - collectibleImage={ - asset.imageUrls[0] || - collectible.imageUrls[0] || - placeholder - } + collectibleImage={[ + ...asset.imageUrls, + ...collectible.imageUrls, + placeholder, + ]} collectibleName={title || collectible.name} currencyImage={props.currencyImage} quantity={props.amount} diff --git a/packages/keychain/src/components/inventory/collection/collectible-purchase.tsx b/packages/keychain/src/components/inventory/collection/collectible-purchase.tsx index 107c147868..4477e0c907 100644 --- a/packages/keychain/src/components/inventory/collection/collectible-purchase.tsx +++ b/packages/keychain/src/components/inventory/collection/collectible-purchase.tsx @@ -21,6 +21,7 @@ import { import { useConnection } from "@/hooks/connection"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useToriiCollection, useToriiCollections } from "@/hooks/collection"; +import { getTokenImageFallbacks } from "@/helpers/torii-url"; import { useToast } from "@/context/toast"; import { useTokens } from "@/hooks/token"; import { useNavigation } from "@/context/navigation"; @@ -184,13 +185,13 @@ export function CollectiblePurchase() { } catch { tokenName = asset.name; } - const newImage = toriiUrl - ? `${toriiUrl}/static/${addAddressPadding(contractAddress)}/${asset.token_id}/image` - : undefined; - const oldImage = toriiUrl - ? `${toriiUrl}/static/0x${BigInt(contractAddress).toString(16)}/${asset.token_id}/image` - : undefined; - const images = [newImage, oldImage].filter(Boolean) as string[]; + const images = toriiUrl + ? getTokenImageFallbacks( + toriiUrl, + contractAddress, + asset.token_id ?? "0x0", + ) + : []; return { orderId: order.id, images, diff --git a/packages/keychain/src/components/inventory/collection/collection-asset.tsx b/packages/keychain/src/components/inventory/collection/collection-asset.tsx index 874b5df56c..05f63ff147 100644 --- a/packages/keychain/src/components/inventory/collection/collection-asset.tsx +++ b/packages/keychain/src/components/inventory/collection/collection-asset.tsx @@ -269,11 +269,11 @@ export function CollectionAsset() { timestamp={props.timestamp} category={props.category} amount={props.amount} - collectibleImage={ - asset.imageUrls[0] || - collection.imageUrls[0] || - placeholder - } + collectibleImage={[ + ...asset.imageUrls, + ...collection.imageUrls, + placeholder, + ]} collectibleName={title || collection.name} currencyImage={props.currencyImage} /> diff --git a/packages/keychain/src/components/inventory/collection/collection-purchase.tsx b/packages/keychain/src/components/inventory/collection/collection-purchase.tsx index 9b7e459676..a3a0151c84 100644 --- a/packages/keychain/src/components/inventory/collection/collection-purchase.tsx +++ b/packages/keychain/src/components/inventory/collection/collection-purchase.tsx @@ -21,6 +21,7 @@ import { import { useConnection } from "@/hooks/connection"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useToriiCollection } from "@/hooks/collection"; +import { getTokenImageFallbacks } from "@/helpers/torii-url"; import { useToast } from "@/context/toast"; import { useTokens } from "@/hooks/token"; import { useTokenContract } from "@/hooks/contracts"; @@ -131,11 +132,16 @@ export function CollectionPurchase() { } catch { tokenName = asset.name; } - const newImage = `${toriiUrl}/static/${addAddressPadding(contractAddress)}/${asset.token_id}/image`; - const oldImage = `${toriiUrl}/static/0x${BigInt(contractAddress).toString(16)}/${asset.token_id}/image`; + const images = toriiUrl + ? getTokenImageFallbacks( + toriiUrl, + contractAddress, + asset.token_id ?? "0x0", + ) + : []; return { orderId: order.id, - images: [newImage, oldImage], + images, name: tokenName, collection: tokenContract.name, collectionAddress: contractAddress, diff --git a/packages/keychain/src/components/provider/data.tsx b/packages/keychain/src/components/provider/data.tsx index 14dfb944e9..c59d709834 100644 --- a/packages/keychain/src/components/provider/data.tsx +++ b/packages/keychain/src/components/provider/data.tsx @@ -7,7 +7,7 @@ import { } from "@cartridge/controller-ui/utils/api/cartridge"; import { useAccount, useUsernames } from "@/hooks/account"; import { useConnection, useControllerTheme } from "@/hooks/connection"; -import { getToriiUrl } from "@/helpers/torii-url"; +import { getTokenImageFallbacks, getToriiUrl } from "@/helpers/torii-url"; import { addAddressPadding, getChecksumAddress } from "starknet"; import { erc20Metadata } from "@cartridge/presets"; import { getDate } from "@cartridge/controller-ui/utils"; @@ -32,6 +32,8 @@ export interface CardProps { name: string; collection: string; image: string; + /** Fallback image candidates for the collectible variant, tried in order. */ + images?: string[]; title: string; color: string; website: string; @@ -226,7 +228,14 @@ export function DataProvider({ children }: { children: ReactNode }) { metadata.attributes?.find( (attribute) => attribute.trait?.toLowerCase() === "name", )?.value || metadata.name; - const image = `${toriiUrl ?? getToriiUrl(item.meta.project)}/static/${addAddressPadding(transfer.contractAddress)}/${transfer.tokenId}/image`; + const base = toriiUrl ?? getToriiUrl(item.meta.project); + const images = base + ? getTokenImageFallbacks( + base, + transfer.contractAddress, + transfer.tokenId, + ) + : []; const userAddress = BigInt(transfer.fromAddress) === BigInt(address) ? transfer.toAddress @@ -242,7 +251,8 @@ export function DataProvider({ children }: { children: ReactNode }) { value: "", name: name || "", collection: transfer.name, - image: image, + image: images[0] || "", + images: images, title: "", color: theme.color, website: "", diff --git a/packages/keychain/src/helpers/torii-url.test.ts b/packages/keychain/src/helpers/torii-url.test.ts index 88ab118f1d..7571a4c5b9 100644 --- a/packages/keychain/src/helpers/torii-url.test.ts +++ b/packages/keychain/src/helpers/torii-url.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { getToriiUrl } from "./torii-url"; +import { + getTokenImageFallbacks, + getToriiCollectionImageUrl, + getToriiTokenImageUrls, + getToriiUrl, +} from "./torii-url"; describe("getToriiUrl compatibility", () => { it("uses an explicit Torii URL ahead of the legacy Slot project", () => { @@ -27,3 +32,61 @@ describe("getToriiUrl compatibility", () => { expect(getToriiUrl(null, null)).toBeNull(); }); }); + +const TORII_URL = "https://api.cartridge.gg/x/gbomb-mainnet/torii"; +const CONTRACT_ADDRESS = + "0x10cf2e2beb27753b7b46248d211614a6c3e4593371716cec9b952e43aaadd6"; +const NORMALIZED_CONTRACT_ADDRESS = + "0x0010cf2e2beb27753b7b46248d211614a6c3e4593371716cec9b952e43aaadd6"; +const TOKEN_ID = + "0x000000000000000000000000000000000000000000000000000000000000001a"; + +describe("getToriiCollectionImageUrl", () => { + it("zero-pads and lowercases the contract address", () => { + expect(getToriiCollectionImageUrl(TORII_URL, CONTRACT_ADDRESS)).toBe( + `${TORII_URL}/static/${NORMALIZED_CONTRACT_ADDRESS}/image`, + ); + }); +}); + +describe("getToriiTokenImageUrls", () => { + it("returns the padded URL first and the legacy unpadded URL second", () => { + expect( + getToriiTokenImageUrls(TORII_URL, CONTRACT_ADDRESS, TOKEN_ID), + ).toEqual([ + `${TORII_URL}/static/${NORMALIZED_CONTRACT_ADDRESS}/${TOKEN_ID}/image`, + `${TORII_URL}/static/${CONTRACT_ADDRESS}/${TOKEN_ID}/image`, + ]); + }); + + it("pads short token ids", () => { + expect( + getToriiTokenImageUrls(TORII_URL, NORMALIZED_CONTRACT_ADDRESS, "0x1a")[0], + ).toBe( + `${TORII_URL}/static/${NORMALIZED_CONTRACT_ADDRESS}/${TOKEN_ID}/image`, + ); + }); +}); + +describe("getTokenImageFallbacks", () => { + it("ends with the collection image so token failures fall back to it", () => { + const urls = getTokenImageFallbacks( + TORII_URL, + CONTRACT_ADDRESS, + TOKEN_ID, + "ipfs://metadata-image", + ); + expect(urls).toEqual([ + `${TORII_URL}/static/${NORMALIZED_CONTRACT_ADDRESS}/${TOKEN_ID}/image`, + `${TORII_URL}/static/${CONTRACT_ADDRESS}/${TOKEN_ID}/image`, + "ipfs://metadata-image", + `${TORII_URL}/static/${NORMALIZED_CONTRACT_ADDRESS}/image`, + ]); + }); + + it("omits the metadata entry when absent", () => { + expect( + getTokenImageFallbacks(TORII_URL, CONTRACT_ADDRESS, TOKEN_ID), + ).toHaveLength(3); + }); +}); diff --git a/packages/keychain/src/helpers/torii-url.ts b/packages/keychain/src/helpers/torii-url.ts index 3d147a01df..e3387a1244 100644 --- a/packages/keychain/src/helpers/torii-url.ts +++ b/packages/keychain/src/helpers/torii-url.ts @@ -1,3 +1,5 @@ +import { addAddressPadding, getChecksumAddress } from "starknet"; + /** * Resolve the Torii base URL — the single source of truth for how a Torii URL * is built across the keychain. @@ -18,3 +20,64 @@ export function getToriiUrl( if (project) return `https://api.cartridge.gg/x/${project}/torii`; return null; } + +/** + * Torii serves static assets under the zero-padded, lowercase form of the + * contract address; any other form 404s. + */ +function normalizeContractAddress(contractAddress: string): string { + return getChecksumAddress(contractAddress).toLowerCase(); +} + +/** + * Collection-level (contract) image served by Torii. Used as the last-resort + * fallback when token-level images are missing or unreadable. + * + * @param toriiUrl - The resolved Torii base URL (see `getToriiUrl`). + * @param contractAddress - The collection contract address, any hex form. + */ +export function getToriiCollectionImageUrl( + toriiUrl: string, + contractAddress: string, +): string { + return `${toriiUrl}/static/${normalizeContractAddress(contractAddress)}/image`; +} + +/** + * Candidate token-level image URLs served by Torii, most canonical first. + * The unpadded-address variant is kept for older Torii instances that stored + * assets under the short hex form. + * + * @param toriiUrl - The resolved Torii base URL (see `getToriiUrl`). + * @param contractAddress - The collection contract address, any hex form. + * @param tokenId - The token id, any hex or decimal form. + */ +export function getToriiTokenImageUrls( + toriiUrl: string, + contractAddress: string, + tokenId: string, +): string[] { + const paddedTokenId = addAddressPadding(tokenId).toLowerCase(); + return [ + `${toriiUrl}/static/${normalizeContractAddress(contractAddress)}/${paddedTokenId}/image`, + `${toriiUrl}/static/0x${BigInt(contractAddress).toString(16)}/${paddedTokenId}/image`, + ]; +} + +/** + * The standard fallback chain for a token image: token-level URLs first, then + * the token metadata image, then the collection-level image. This mirrors the + * chain used by the asset page preview, which is the reference behavior. + */ +export function getTokenImageFallbacks( + toriiUrl: string, + contractAddress: string, + tokenId: string, + metadataImage?: string, +): string[] { + return [ + ...getToriiTokenImageUrls(toriiUrl, contractAddress, tokenId), + ...(metadataImage ? [metadataImage] : []), + getToriiCollectionImageUrl(toriiUrl, contractAddress), + ]; +} diff --git a/packages/keychain/src/hooks/collection.ts b/packages/keychain/src/hooks/collection.ts index 80a11aa070..37826a2103 100644 --- a/packages/keychain/src/hooks/collection.ts +++ b/packages/keychain/src/hooks/collection.ts @@ -7,6 +7,11 @@ import { useConnection } from "@/hooks/connection"; import { addAddressPadding } from "starknet"; import * as torii from "@dojoengine/torii-wasm"; import Torii from "@/helpers/torii"; +import { + getTokenImageFallbacks, + getToriiCollectionImageUrl, + getToriiTokenImageUrls, +} from "@/helpers/torii-url"; export const ERC721 = "ERC721"; export const ERC1155 = "ERC1155"; @@ -62,7 +67,8 @@ export function useCollection({ }, [toriiUrl]); useEffect(() => { - if (!client || !address || !trigger || !contractAddress) return; + if (!client || !address || !trigger || !contractAddress || !toriiUrl) + return; setTrigger(false); const getCollections = async () => { const contract = await Torii.fetchContract(client, contractAddress); @@ -97,14 +103,15 @@ export function useCollection({ console.error(error); } if (!metadata.name || !metadata.image) return; - const contractImage = `${toriiUrl}/static/${addAddressPadding(contractAddress)}/image`; - const oldImage = `${toriiUrl}/static/0x${BigInt(contractAddress).toString(16)}/${asset.token_id}/image`; - const newImage = `${toriiUrl}/static/${addAddressPadding(contractAddress)}/${asset.token_id}/image`; const newCollection: Collection = { address: contractAddress, name: asset.name || metadata.name, type: ERC721, - imageUrls: [contractImage, newImage, oldImage, metadata.image], + imageUrls: [ + getToriiCollectionImageUrl(toriiUrl, contractAddress), + ...getToriiTokenImageUrls(toriiUrl, contractAddress, asset.token_id!), + metadata.image, + ], totalCount: ids.length, }; setCollection(newCollection); @@ -130,8 +137,6 @@ export function useCollection({ BigInt(b.balance) !== 0n, )?.account_address; if (!owner) return; // Skip assets without owners - const oldImage = `${toriiUrl}/static/0x${BigInt(contractAddress).toString(16)}/${asset.token_id}/image`; - const newImage = `${toriiUrl}/static/${addAddressPadding(contractAddress)}/${asset.token_id}/image`; const balance = balances.find( (b) => @@ -141,7 +146,12 @@ export function useCollection({ tokenId: asset.token_id || "", name: metadata?.name || asset.name, description: metadata?.description, - imageUrls: [newImage, oldImage, metadata?.image || ""], + imageUrls: getTokenImageFallbacks( + toriiUrl, + contractAddress, + asset.token_id!, + metadata?.image, + ), attributes: Array.isArray(metadata?.attributes) ? metadata.attributes : [], @@ -210,7 +220,7 @@ export function useCollections(): UseCollectionsResponse { }, [toriiUrl]); useEffect(() => { - if (!client || !address || !trigger) return; + if (!client || !address || !trigger || !toriiUrl) return; setTrigger(false); const getCollections = async () => { const contracts = await Torii.fetchContracts(client, [ERC721, ERC1155]); @@ -249,8 +259,6 @@ export function useCollections(): UseCollectionsResponse { } catch (error) { console.error(error); } - const oldImage = `${toriiUrl}/static/0x${BigInt(contractAddress).toString(16)}/${asset.token_id}/image`; - const newImage = `${toriiUrl}/static/${addAddressPadding(contractAddress)}/${asset.token_id}/image`; const type = contracts.find( (c) => c.contract_address === contractAddress, )?.contract_type; @@ -258,7 +266,15 @@ export function useCollections(): UseCollectionsResponse { address: contractAddress, name: asset.name || metadata?.name || "", type: type || "", - imageUrls: [newImage, oldImage, metadata?.image || ""], + imageUrls: [ + getToriiCollectionImageUrl(toriiUrl, contractAddress), + ...getToriiTokenImageUrls( + toriiUrl, + contractAddress, + asset.token_id!, + ), + metadata?.image || "", + ], totalCount: tokenIds.length, }; }), diff --git a/packages/ui/src/components/modules/activities/card/collectible-card.tsx b/packages/ui/src/components/modules/activities/card/collectible-card.tsx index 2e1d42d09a..6449f66627 100644 --- a/packages/ui/src/components/modules/activities/card/collectible-card.tsx +++ b/packages/ui/src/components/modules/activities/card/collectible-card.tsx @@ -24,7 +24,7 @@ export interface ActivityCollectibleCardProps address: string; // token address username?: string; // token owner username collection?: string; - image?: string; // token image + image?: string | string[]; // token image, with optional fallback candidates logo?: string; // game logo orderAmount?: string; // order amount orderImage?: string; // order token image diff --git a/packages/ui/src/components/modules/thumbnails/collectible/collectible.tsx b/packages/ui/src/components/modules/thumbnails/collectible/collectible.tsx index abcf610a60..c27a0bfe3a 100644 --- a/packages/ui/src/components/modules/thumbnails/collectible/collectible.tsx +++ b/packages/ui/src/components/modules/thumbnails/collectible/collectible.tsx @@ -2,6 +2,7 @@ import { Thumbnail } from "@/index"; import { cn } from "@/utils"; import { cva, VariantProps } from "class-variance-authority"; import { PLACEHOLDER } from "@/assets"; +import { useEffect, useMemo, useState } from "react"; const thumbnailCollectibleVariants = cva("border-transparent", { variants: { @@ -32,7 +33,7 @@ const thumbnailCollectibleVariants = cva("border-transparent", { export interface ThumbnailCollectibleProps extends VariantProps { - image: string; + image: string | string[]; subIcon?: React.ReactNode; error?: boolean; loading?: boolean; @@ -48,13 +49,14 @@ export const ThumbnailCollectible = ({ size, className, }: ThumbnailCollectibleProps) => { + const displayImage = useFirstLoadableImage(image); return (
{ e.currentTarget.src = PLACEHOLDER; @@ -68,7 +70,7 @@ export const ThumbnailCollectible = ({ "object-contain max-h-full max-w-full z-10 relative border-0", )} draggable={false} - src={image} + src={displayImage} onError={(e) => { e.currentTarget.src = PLACEHOLDER; }} @@ -89,4 +91,50 @@ export const ThumbnailCollectible = ({ ); }; +/** + * Resolve the first source that actually loads as an image. Torii can serve a + * 200 response that is not decodable (e.g. token metadata containing a raw URL + * instead of image data), so each candidate is probed before being displayed. + */ +function useFirstLoadableImage(image: string | string[]): string { + const sources = useMemo( + () => (Array.isArray(image) ? image : [image]).filter(Boolean), + [Array.isArray(image) ? image.join(",") : image], + ); + const [resolved, setResolved] = useState(() => + sources.length === 1 ? sources[0] : undefined, + ); + + useEffect(() => { + // Single source keeps the legacy behavior: render it directly and let the + // onError handlers swap in the placeholder. + if (sources.length <= 1) { + setResolved(sources[0] ?? PLACEHOLDER); + return; + } + let isMounted = true; + let index = 0; + const loadNext = () => { + if (!isMounted) return; + if (index >= sources.length) { + setResolved(PLACEHOLDER); + return; + } + const source = sources[index++]; + const loader = new window.Image(); + loader.onload = () => { + if (isMounted) setResolved(source); + }; + loader.onerror = () => loadNext(); + loader.src = source; + }; + loadNext(); + return () => { + isMounted = false; + }; + }, [sources]); + + return resolved ?? sources[0] ?? PLACEHOLDER; +} + export default ThumbnailCollectible; diff --git a/packages/ui/src/components/modules/traceabilities/card/collectible-card.tsx b/packages/ui/src/components/modules/traceabilities/card/collectible-card.tsx index d68fb0aefb..7ad09aefc5 100644 --- a/packages/ui/src/components/modules/traceabilities/card/collectible-card.tsx +++ b/packages/ui/src/components/modules/traceabilities/card/collectible-card.tsx @@ -17,7 +17,7 @@ export interface TraceabilityCollectibleCardProps username: string; timestamp: number; category: "send" | "receive" | "mint" | "sale" | "list"; - collectibleImage: string; + collectibleImage: string | string[]; collectibleName: string; currencyImage?: string; amount?: number; From 5271cbdbfa405b46236ac9403acd3b29f892a3e4 Mon Sep 17 00:00:00 2001 From: bal7hazar Date: Wed, 22 Jul 2026 19:39:26 +0200 Subject: [PATCH 2/2] fix(keychain): resolve nested NFT image URLs in fallback chain Integrate the nested data-URI decoding from #2664: when token metadata wraps a plain URL inside a base64 data URI (e.g. Glitch Bomb), decode it and promote it ahead of the Torii candidates so the intended image loads first. Collection-level lists resolve the nested URI in place. Supersedes #2664. Co-Authored-By: Claude Fable 5 --- .../keychain/src/helpers/image-url.test.ts | 48 +++++++++++++++++++ packages/keychain/src/helpers/image-url.ts | 45 +++++++++++++++++ .../keychain/src/helpers/torii-url.test.ts | 13 +++++ packages/keychain/src/helpers/torii-url.ts | 11 ++++- packages/keychain/src/hooks/collection.ts | 5 +- 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 packages/keychain/src/helpers/image-url.test.ts create mode 100644 packages/keychain/src/helpers/image-url.ts diff --git a/packages/keychain/src/helpers/image-url.test.ts b/packages/keychain/src/helpers/image-url.test.ts new file mode 100644 index 0000000000..2d4b2858bc --- /dev/null +++ b/packages/keychain/src/helpers/image-url.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveMetadataImage, resolveNestedImageUri } from "./image-url"; + +const MALFORMED_IMAGE_URI = + "data:image/svg+xml;base64,aHR0cHM6Ly9zdGF0aWMuY2FydHJpZGdlLmdnL3ByZXNldHMvZ2xpdGNoLWJvbWIvaWNvbi5wbmc="; +const NESTED_IMAGE_URI = + "https://static.cartridge.gg/presets/glitch-bomb/icon.png"; + +describe("resolveNestedImageUri", () => { + it("resolves an image URL encoded inside a data URI", () => { + expect(resolveNestedImageUri(MALFORMED_IMAGE_URI)).toBe(NESTED_IMAGE_URI); + }); + + it("does not unwrap valid inline SVG data", () => { + const svgDataUri = "data:image/svg+xml;base64,PHN2Zy8+"; + + expect(resolveNestedImageUri(svgDataUri)).toBeUndefined(); + }); + + it("ignores plain URLs", () => { + expect( + resolveNestedImageUri("https://example.com/token.png"), + ).toBeUndefined(); + }); + + it("ignores invalid base64 payloads", () => { + expect( + resolveNestedImageUri("data:image/svg+xml;base64,%%%"), + ).toBeUndefined(); + }); +}); + +describe("resolveMetadataImage", () => { + it("returns the nested URI for malformed data URIs", () => { + expect(resolveMetadataImage(MALFORMED_IMAGE_URI)).toBe(NESTED_IMAGE_URI); + }); + + it("returns regular metadata images untouched", () => { + expect(resolveMetadataImage("ipfs://token-image")).toBe( + "ipfs://token-image", + ); + }); + + it("returns undefined for empty input", () => { + expect(resolveMetadataImage(undefined)).toBeUndefined(); + expect(resolveMetadataImage("")).toBeUndefined(); + }); +}); diff --git a/packages/keychain/src/helpers/image-url.ts b/packages/keychain/src/helpers/image-url.ts new file mode 100644 index 0000000000..22ad4989f5 --- /dev/null +++ b/packages/keychain/src/helpers/image-url.ts @@ -0,0 +1,45 @@ +const SUPPORTED_IMAGE_URI = /^(?:https?:\/\/|ipfs:\/\/|data:)/; + +/** + * Decode a malformed NFT image data URI whose payload is actually a nested + * HTTP, IPFS, or data URI rather than image data. Some games (e.g. Glitch + * Bomb) publish token metadata like + * `data:image/svg+xml;base64,`, which browsers cannot + * decode as an image. Returns undefined for well-formed data URIs. + * + * Adapted from cartridge-gg/controller#2664. + */ +export function resolveNestedImageUri(imageUri: string): string | undefined { + if (!imageUri.startsWith("data:")) return; + + const separator = imageUri.indexOf(","); + if (separator === -1) return; + + const header = imageUri.slice(5, separator); + const payload = imageUri.slice(separator + 1); + + try { + const decoded = header.toLowerCase().split(";").includes("base64") + ? new TextDecoder().decode( + Uint8Array.from(atob(payload), (character) => + character.charCodeAt(0), + ), + ) + : decodeURIComponent(payload); + const nestedUri = decoded.trim(); + + return SUPPORTED_IMAGE_URI.test(nestedUri) ? nestedUri : undefined; + } catch { + return; + } +} + +/** + * A metadata image URI with malformed nesting resolved: returns the nested + * URI when the input is a data URI wrapping one, the input untouched + * otherwise. + */ +export function resolveMetadataImage(imageUri?: string): string | undefined { + if (!imageUri) return undefined; + return resolveNestedImageUri(imageUri) ?? imageUri; +} diff --git a/packages/keychain/src/helpers/torii-url.test.ts b/packages/keychain/src/helpers/torii-url.test.ts index 7571a4c5b9..529fc328ad 100644 --- a/packages/keychain/src/helpers/torii-url.test.ts +++ b/packages/keychain/src/helpers/torii-url.test.ts @@ -89,4 +89,17 @@ describe("getTokenImageFallbacks", () => { getTokenImageFallbacks(TORII_URL, CONTRACT_ADDRESS, TOKEN_ID), ).toHaveLength(3); }); + + it("promotes a nested metadata image URL ahead of Torii candidates", () => { + const malformed = + "data:image/svg+xml;base64,aHR0cHM6Ly9zdGF0aWMuY2FydHJpZGdlLmdnL3ByZXNldHMvZ2xpdGNoLWJvbWIvaWNvbi5wbmc="; + expect( + getTokenImageFallbacks(TORII_URL, CONTRACT_ADDRESS, TOKEN_ID, malformed), + ).toEqual([ + "https://static.cartridge.gg/presets/glitch-bomb/icon.png", + `${TORII_URL}/static/${NORMALIZED_CONTRACT_ADDRESS}/${TOKEN_ID}/image`, + `${TORII_URL}/static/${CONTRACT_ADDRESS}/${TOKEN_ID}/image`, + `${TORII_URL}/static/${NORMALIZED_CONTRACT_ADDRESS}/image`, + ]); + }); }); diff --git a/packages/keychain/src/helpers/torii-url.ts b/packages/keychain/src/helpers/torii-url.ts index e3387a1244..6deef091f7 100644 --- a/packages/keychain/src/helpers/torii-url.ts +++ b/packages/keychain/src/helpers/torii-url.ts @@ -1,4 +1,5 @@ import { addAddressPadding, getChecksumAddress } from "starknet"; +import { resolveNestedImageUri } from "./image-url"; /** * Resolve the Torii base URL — the single source of truth for how a Torii URL @@ -68,6 +69,10 @@ export function getToriiTokenImageUrls( * The standard fallback chain for a token image: token-level URLs first, then * the token metadata image, then the collection-level image. This mirrors the * chain used by the asset page preview, which is the reference behavior. + * + * When the metadata image is a malformed data URI wrapping a plain URL (see + * `resolveNestedImageUri`), the decoded URL is the image the game intended, + * so it is promoted ahead of the Torii candidates (cartridge-gg/controller#2664). */ export function getTokenImageFallbacks( toriiUrl: string, @@ -75,9 +80,13 @@ export function getTokenImageFallbacks( tokenId: string, metadataImage?: string, ): string[] { + const nestedUri = metadataImage + ? resolveNestedImageUri(metadataImage) + : undefined; return [ + ...(nestedUri ? [nestedUri] : []), ...getToriiTokenImageUrls(toriiUrl, contractAddress, tokenId), - ...(metadataImage ? [metadataImage] : []), + ...(!nestedUri && metadataImage ? [metadataImage] : []), getToriiCollectionImageUrl(toriiUrl, contractAddress), ]; } diff --git a/packages/keychain/src/hooks/collection.ts b/packages/keychain/src/hooks/collection.ts index 37826a2103..bbbef68bff 100644 --- a/packages/keychain/src/hooks/collection.ts +++ b/packages/keychain/src/hooks/collection.ts @@ -12,6 +12,7 @@ import { getToriiCollectionImageUrl, getToriiTokenImageUrls, } from "@/helpers/torii-url"; +import { resolveMetadataImage } from "@/helpers/image-url"; export const ERC721 = "ERC721"; export const ERC1155 = "ERC1155"; @@ -110,7 +111,7 @@ export function useCollection({ imageUrls: [ getToriiCollectionImageUrl(toriiUrl, contractAddress), ...getToriiTokenImageUrls(toriiUrl, contractAddress, asset.token_id!), - metadata.image, + resolveMetadataImage(metadata.image) || "", ], totalCount: ids.length, }; @@ -273,7 +274,7 @@ export function useCollections(): UseCollectionsResponse { contractAddress, asset.token_id!, ), - metadata?.image || "", + resolveMetadataImage(metadata?.image) || "", ], totalCount: tokenIds.length, };