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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/keychain/src/components/activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions packages/keychain/src/components/provider/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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: "",
Expand Down
48 changes: 48 additions & 0 deletions packages/keychain/src/helpers/image-url.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
45 changes: 45 additions & 0 deletions packages/keychain/src/helpers/image-url.ts
Original file line number Diff line number Diff line change
@@ -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,<base64 of a plain URL>`, 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;
}
78 changes: 77 additions & 1 deletion packages/keychain/src/helpers/torii-url.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -27,3 +32,74 @@ 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);
});

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`,
]);
});
});
Loading
Loading