diff --git a/@shared/api/helpers/__tests__/fetchCollectibles.test.ts b/@shared/api/helpers/__tests__/fetchCollectibles.test.ts index 28e11f196c..c61997741a 100644 --- a/@shared/api/helpers/__tests__/fetchCollectibles.test.ts +++ b/@shared/api/helpers/__tests__/fetchCollectibles.test.ts @@ -1,4 +1,7 @@ -import { fetchCollectibles } from "../fetchCollectibles"; +import { + fetchCollectibleMetadata, + fetchCollectibles, +} from "../fetchCollectibles"; import { fetchMetadataJson } from "../fetchMetadataJson"; import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { SERVICE_TYPES } from "@shared/constants/services"; @@ -15,6 +18,65 @@ jest.mock("../extensionMessaging", () => ({ const mockedFetchMetadataJson = fetchMetadataJson as jest.Mock; const mockedSendMessageToBackground = sendMessageToBackground as jest.Mock; +describe("fetchCollectibleMetadata", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should read metadata embedded in a data URI without fetching", async () => { + const inlineMetadata = { + name: "Stroopies 0", + image: "data:image/svg+xml,", + attributes: [{ trait_type: "role", value: 3 }], + }; + + const metadata = await fetchCollectibleMetadata( + `data:application/json,${JSON.stringify(inlineMetadata)}`, + ); + + expect(mockedFetchMetadataJson).not.toHaveBeenCalled(); + expect(metadata).toEqual({ + name: "Stroopies 0", + description: undefined, + image: "data:image/svg+xml,", + externalUrl: undefined, + attributes: [{ traitType: "role", value: 3 }], + }); + }); + + it("should return null for a data URI that cannot be parsed", async () => { + const metadata = await fetchCollectibleMetadata("data:text/html,

hi

"); + + expect(mockedFetchMetadataJson).not.toHaveBeenCalled(); + expect(metadata).toBeNull(); + }); + + it("should keep traits with a value of 0 and drop traits with no value", async () => { + mockedFetchMetadataJson.mockResolvedValue({ + name: "Collectible Name", + attributes: [ + { trait_type: "arms", value: 0 }, + { trait_type: "hat", value: 8 }, + { trait_type: "color", value: "blue" }, + { trait_type: "empty", value: "" }, + { trait_type: "missing" }, + { trait_type: "nullish", value: null }, + { value: "no trait type" }, + ], + }); + + const metadata = await fetchCollectibleMetadata( + "https://nftcalendar.io/token/1", + ); + + expect(metadata?.attributes).toEqual([ + { traitType: "arms", value: 0 }, + { traitType: "hat", value: 8 }, + { traitType: "color", value: "blue" }, + ]); + }); +}); + describe("fetchCollectibles", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/@shared/api/helpers/__tests__/parseMetadataDataUri.test.ts b/@shared/api/helpers/__tests__/parseMetadataDataUri.test.ts new file mode 100644 index 0000000000..86cc04f99f --- /dev/null +++ b/@shared/api/helpers/__tests__/parseMetadataDataUri.test.ts @@ -0,0 +1,135 @@ +import { MAX_METADATA_BYTES } from "../fetchMetadataJson"; +import { + isMetadataDataUri, + parseMetadataDataUri, +} from "../parseMetadataDataUri"; + +const metadata = { + name: "Stroopies 0", + image: + "data:image/svg+xml,", + attributes: [ + { trait_type: "role", value: 3 }, + { trait_type: "arms", value: 0 }, + ], +}; + +const toBase64 = (s: string) => Buffer.from(s, "utf-8").toString("base64"); + +describe("isMetadataDataUri", () => { + it("matches data URIs regardless of scheme casing", () => { + expect(isMetadataDataUri("data:application/json,{}")).toBe(true); + expect(isMetadataDataUri("DATA:application/json,{}")).toBe(true); + }); + + it("does not match remote or empty URIs", () => { + expect(isMetadataDataUri("https://example.com/metadata.json")).toBe(false); + expect(isMetadataDataUri("ipfs://bafy")).toBe(false); + expect(isMetadataDataUri("")).toBe(false); + }); +}); + +describe("parseMetadataDataUri", () => { + it("parses a raw (unencoded) JSON payload with an inline SVG image", () => { + const uri = `data:application/json,${JSON.stringify(metadata)}`; + + expect(parseMetadataDataUri(uri)).toEqual(metadata); + }); + + it("parses a percent-encoded JSON payload", () => { + const uri = `data:application/json,${encodeURIComponent( + JSON.stringify(metadata), + )}`; + + expect(parseMetadataDataUri(uri)).toEqual(metadata); + }); + + it("parses a base64 JSON payload, including non-ASCII text", () => { + const doc = { name: "Café ☕" }; + const uri = `data:application/json;base64,${toBase64(JSON.stringify(doc))}`; + + expect(parseMetadataDataUri(uri)).toEqual(doc); + }); + + it("parses a base64 payload whose characters are percent-encoded", () => { + const doc = { name: "AB" }; + const encoded = encodeURIComponent(toBase64(JSON.stringify(doc))); + expect(encoded).toContain("%3D"); + + expect( + parseMetadataDataUri(`data:application/json;base64,${encoded}`), + ).toEqual(doc); + }); + + it("keeps percent escapes inside a raw JSON payload as written", () => { + const doc = { + name: "X", + image: `data:image/svg+xml,${encodeURIComponent( + ``, + )}`, + }; + const uri = `data:application/json,${JSON.stringify(doc)}`; + + expect(parseMetadataDataUri(uri)).toEqual(doc); + }); + + it("accepts media type parameters and mixed casing", () => { + const uri = `data:Application/JSON;charset=utf-8;base64,${toBase64( + JSON.stringify(metadata), + )}`; + + expect(parseMetadataDataUri(uri)).toEqual(metadata); + }); + + it("falls back to the raw payload when it is not valid percent-encoding", () => { + const doc = { + image: "data:image/svg+xml,", + }; + const uri = `data:application/json,${JSON.stringify(doc)}`; + + expect(parseMetadataDataUri(uri)).toEqual(doc); + }); + + it("rejects anything that is not a data:application/json URI", () => { + [ + "https://example.com/metadata.json", + "data:text/html,

hi

", + 'data:,{"name":"x"}', + "data:application/json", + ].forEach((uri) => { + expect(() => parseMetadataDataUri(uri)).toThrow( + "not a data:application/json URI", + ); + }); + }); + + it("rejects malformed JSON", () => { + expect(() => parseMetadataDataUri("data:application/json,{nope")).toThrow(); + }); + + it("rejects malformed base64", () => { + expect(() => + parseMetadataDataUri("data:application/json;base64,***"), + ).toThrow(); + }); + + it("rejects JSON that is not an object", () => { + expect(() => parseMetadataDataUri("data:application/json,[1,2]")).toThrow( + "not a JSON object", + ); + expect(() => parseMetadataDataUri("data:application/json,null")).toThrow( + "not a JSON object", + ); + expect(() => parseMetadataDataUri('data:application/json,"text"')).toThrow( + "not a JSON object", + ); + }); + + it("rejects a URI larger than MAX_METADATA_BYTES", () => { + const uri = `data:application/json,${JSON.stringify({ + name: "x".repeat(MAX_METADATA_BYTES), + })}`; + + expect(() => parseMetadataDataUri(uri)).toThrow("uri too large"); + }); +}); diff --git a/@shared/api/helpers/fetchCollectibles.ts b/@shared/api/helpers/fetchCollectibles.ts index 69f419c977..ab61551681 100644 --- a/@shared/api/helpers/fetchCollectibles.ts +++ b/@shared/api/helpers/fetchCollectibles.ts @@ -7,6 +7,10 @@ import { } from "../types"; import { NetworkDetails } from "@shared/constants/stellar"; import { fetchMetadataJson } from "./fetchMetadataJson"; +import { + isMetadataDataUri, + parseMetadataDataUri, +} from "./parseMetadataDataUri"; import { fetchBackendV2 } from "./fetchBackendV2"; import { redactErrorBody } from "./redactErrorBody"; @@ -15,7 +19,7 @@ import { redactErrorBody } from "./redactErrorBody"; * The metadata follows the NFT metadata standard and includes name, description, * image, external URL, and attributes. * - * @param {string} tokenUri - The URI where the collectible metadata is hosted + * @param {string} tokenUri - The URI where the collectible metadata is hosted (https), or a `data:application/json` URI that embeds it * @returns {Promise} The collectible metadata object, or null if the fetch fails or the response is invalid */ export const fetchCollectibleMetadata = async (tokenUri: string) => { @@ -28,7 +32,11 @@ export const fetchCollectibleMetadata = async (tokenUri: string) => { }; try { - const data = await fetchMetadataJson(tokenUri); + // Some contracts return the metadata document itself as a `data:` URI + // instead of a link to it; read those inline rather than fetching. + const data = isMetadataDataUri(tokenUri) + ? parseMetadataDataUri(tokenUri) + : await fetchMetadataJson(tokenUri); if (!data) { return null; @@ -39,7 +47,10 @@ export const fetchCollectibleMetadata = async (tokenUri: string) => { metadata.externalUrl = data.external_url; metadata.attributes = (data.attributes || []) .map((attr) => { - if (attr.trait_type && attr.value) { + // 0 is a real trait value, so only skip values that are missing + const hasValue = + attr.value !== undefined && attr.value !== null && attr.value !== ""; + if (attr.trait_type && hasValue) { return { traitType: attr.trait_type, value: attr.value, diff --git a/@shared/api/helpers/parseMetadataDataUri.ts b/@shared/api/helpers/parseMetadataDataUri.ts new file mode 100644 index 0000000000..ad1f6b5e94 --- /dev/null +++ b/@shared/api/helpers/parseMetadataDataUri.ts @@ -0,0 +1,73 @@ +import { MAX_METADATA_BYTES } from "./fetchMetadataJson"; + +// `data:application/json[;param...],` — group 1 holds the parameters. The +// payload may itself contain commas, so only the first one ends the header. +const JSON_DATA_URI_HEADER = /^data:application\/json((?:;[^;,]*)*),/i; + +/** + * Whether a token URI carries its metadata inline as a `data:` URI rather + * than pointing at a remote document. + */ +export const isMetadataDataUri = (uri: string) => /^data:/i.test(uri); + +// Payloads are not always valid percent-encoding, so fall back to the payload +// as written when decoding fails. +const decodePercentEncoded = (payload: string) => { + try { + return decodeURIComponent(payload); + } catch { + return payload; + } +}; + +// Percent-decode before base64-decoding, as browsers do. +const decodeBase64 = (payload: string) => { + const binary = atob(decodePercentEncoded(payload).trim()); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder("utf-8").decode(bytes); +}; + +// Raw JSON can contain percent escapes that decoding would corrupt, so parse +// the payload as written first and only decode it if that fails. +const parseJson = (text: string): unknown => { + try { + return JSON.parse(text); + } catch { + return JSON.parse(decodePercentEncoded(text)); + } +}; + +/** + * Parses a JSON metadata document embedded in a `data:` URI, e.g. + * `data:application/json,{"name":"..."}` or + * `data:application/json;base64,eyJuYW1lIjoi...`. + * + * The inline counterpart of fetchMetadataJson: no network call is made, only + * the `application/json` media type is accepted, the URI is capped at + * MAX_METADATA_BYTES, and the document must be a JSON object. Throws on any + * violation so callers can treat it like a failed fetch. + */ +export const parseMetadataDataUri = (uri: string): T => { + if (uri.length > MAX_METADATA_BYTES) { + throw new Error( + `parseMetadataDataUri: uri too large (max ${MAX_METADATA_BYTES} bytes)`, + ); + } + + const header = JSON_DATA_URI_HEADER.exec(uri); + if (!header) { + throw new Error( + "parseMetadataDataUri: uri is not a data:application/json URI", + ); + } + + const payload = uri.slice(header[0].length); + const parsed = parseJson( + /;base64$/i.test(header[1]) ? decodeBase64(payload) : payload, + ); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("parseMetadataDataUri: document is not a JSON object"); + } + + return parsed as T; +};