Skip to content
Open
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
64 changes: 63 additions & 1 deletion @shared/api/helpers/__tests__/fetchCollectibles.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,<svg xmlns='http://www.w3.org/2000/svg'/>",
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,<svg xmlns='http://www.w3.org/2000/svg'/>",
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,<p>hi</p>");

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();
Expand Down
135 changes: 135 additions & 0 deletions @shared/api/helpers/__tests__/parseMetadataDataUri.test.ts
Original file line number Diff line number Diff line change
@@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' fill='rgb(47,47,50)'/></svg>",
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(
`<svg xmlns="http://www.w3.org/2000/svg"></svg>`,
)}`,
};
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,<svg width='100%' height='100%'/>",
};
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,<p>hi</p>",
'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");
});
});
17 changes: 14 additions & 3 deletions @shared/api/helpers/fetchCollectibles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<CollectibleMetadata | null>} The collectible metadata object, or null if the fetch fails or the response is invalid
*/
export const fetchCollectibleMetadata = async (tokenUri: string) => {
Expand All @@ -28,7 +32,11 @@ export const fetchCollectibleMetadata = async (tokenUri: string) => {
};

try {
const data = await fetchMetadataJson<CollectibleMetadataResponse>(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<CollectibleMetadataResponse>(tokenUri)
: await fetchMetadataJson<CollectibleMetadataResponse>(tokenUri);

if (!data) {
return null;
Expand All @@ -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,
Expand Down
73 changes: 73 additions & 0 deletions @shared/api/helpers/parseMetadataDataUri.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(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;
};