diff --git a/fixtures/mst-token-234.json b/fixtures/mst-token-234.json new file mode 100644 index 0000000..b5652e2 --- /dev/null +++ b/fixtures/mst-token-234.json @@ -0,0 +1,26 @@ +{ + "name": "MST #234", + "description": "Mibera Shadow Traits is an infinite collection of NFTs you can build yourself and mint.", + "image": "https://assets.0xhoneyjar.xyz/Mibera/generated/234.webp", + "attributes": [ + { "trait_type": "background", "value": "no more walls" }, + { "trait_type": "body", "value": "mahogany" }, + { "trait_type": "earrings", "value": "experimental" }, + { "trait_type": "eyebrows", "value": "intense" }, + { "trait_type": "eyes", "value": "crossed leaf" }, + { "trait_type": "face accessories", "value": "indian paint" }, + { "trait_type": "glasses", "value": "black white sunglasses" }, + { "trait_type": "hair", "value": "braided black" }, + { "trait_type": "hats", "value": "spiral tribe" }, + { "trait_type": "items", "value": "bong bear 43" }, + { "trait_type": "mouth", "value": "queasy" }, + { "trait_type": "shirts", "value": "maid" }, + { "trait_type": "tattoos", "value": "from the full moon fell nokomis" }, + { "trait_type": "sun", "value": "sagittarius" }, + { "trait_type": "moon", "value": "pisces" }, + { "trait_type": "rising", "value": "libra" }, + { "trait_type": "drug", "value": "sakae naa" }, + { "trait_type": "elements", "value": "earth" }, + { "trait_type": "shadow ranking", "value": "indigo" } + ] +} diff --git a/src/inventory.ts b/src/inventory.ts index 08f9e63..a449fe1 100644 --- a/src/inventory.ts +++ b/src/inventory.ts @@ -6,6 +6,7 @@ import { buildEnvelope, buildEnvelopeLive } from "./completeness.js"; import { applyPagination } from "./pagination.js"; import { toChecksumAddress, isValidAddress } from "./address.js"; import { ValidationError, NotFoundError } from "./errors.js"; +import { fetchMstMetadata } from "./sovereign-metadata.js"; import type { HoldingsResponse, ContractHolding, @@ -19,6 +20,19 @@ const MIBERA_CONTRACT = "0x6666397DFe9a8c469BF65dc744CB1C733416c420"; const MIBERA_CHAIN_ID = 80094; const MIBERA_COLLECTION_KEY = "mibera"; +// Mibera Shadow (MST) — chain 80094. Metadata resolves via the SOVEREIGN +// storage-api route (src/sovereign-metadata.ts), NOT the Mibera-main codex. +const MST_CONTRACT = "0x048327a187b944ddac61c6e202bfccd20d17c008"; + +// Contract → metadata resolution strategy. Keyed by EIP-55 checksum address so the +// lookup is checksum-consistent (never raw-case string compare). Add a row here to +// support a new collection rather than growing an if/else chain. +type MetadataStrategy = "codex" | "sovereign-mst"; +const METADATA_REGISTRY: Record = { + [toChecksumAddress(MIBERA_CONTRACT)]: "codex", + [toChecksumAddress(MST_CONTRACT)]: "sovereign-mst", +}; + function validateAddress(address: string, field: string): string { if (!isValidAddress(address)) { throw new ValidationError(field, address, "0x-prefixed 40-char hex string"); @@ -156,11 +170,21 @@ export async function getNftMetadata( contract: string, tokenId: string ): Promise { - validateAddress(contract, "contract"); + const checksummedContract = validateAddress(contract, "contract"); if (!/^\d+$/.test(tokenId)) { throw new ValidationError("tokenId", tokenId, "numeric string"); } + // Branch on the (checksummed) contract: Mibera-main resolves from the codex, + // Mibera Shadow (MST) from the sovereign storage-api route. Unknown contracts + // default to the codex path (preserves prior behavior: codex miss => NotFoundError). + const strategy = METADATA_REGISTRY[checksummedContract] ?? "codex"; + + if (strategy === "sovereign-mst") { + return fetchMstMetadata(checksummedContract, tokenId); + } + + // Mibera-main codex path (unchanged). const record = codexClient.getToken(tokenId); if (!record) { throw new NotFoundError(tokenId, contract); diff --git a/src/sovereign-metadata.ts b/src/sovereign-metadata.ts new file mode 100644 index 0000000..7a3b42b --- /dev/null +++ b/src/sovereign-metadata.ts @@ -0,0 +1,95 @@ +// Sovereign MST (Mibera Shadow) metadata resolver. +// +// Mirrors `@freeside-storage/client`'s +// lookupSovereignManifest({ world: "mibera", collection: "mst", tokenId }) +// i.e. the sovereign url-contract for Mibera Shadow tokens. That client package is +// private/workspace-only and pulls `effect`, so per the coordinator decision we +// mirror the governed route locally rather than vendor it (zero new runtime deps; +// global `fetch` only, the same primitive used in src/live-sonar.ts). +// +// The route is governed by the storage-api URL contract (shipped 2026-05-01, +// "mst-sovereign-cutover"; semver + 90d-deprecation). If the route ever moves, +// keep this module in sync with that contract. +// +// Scope: MST metadata source is this SOVEREIGN storage-api route ONLY — no chain +// RPC, no tokenURI, no sonar GraphQL, no honeyroad. The live JSON shape is already +// `{ name, description, image, attributes: [{ trait_type, value }] }` (verified live). + +import { NotFoundError } from "./errors.js"; +import type { Attribute, MetadataDocument } from "../types.js"; + +const METADATA_BASE = "https://metadata.0xhoneyjar.xyz"; + +/** Sovereign storage-api URL for an MST (Mibera Shadow) token's metadata. */ +export function mstMetadataUrl(tokenId: string): string { + return `${METADATA_BASE}/mibera/mst/${tokenId}`; +} + +/** Defensive coercion of the sovereign JSON `attributes` array. */ +function mapAttributes(raw: unknown): Attribute[] { + if (!Array.isArray(raw)) return []; + return raw.map((entry) => { + const a = (entry ?? {}) as { trait_type?: unknown; value?: unknown }; + return { + trait_type: String(a.trait_type ?? ""), + value: String(a.value ?? ""), + }; + }); +} + +/** + * Resolve MST (Mibera Shadow) metadata from the sovereign storage-api. + * + * - 4xx (e.g. 403/404 — unminted tokens return 403): throws NotFoundError, mirroring + * the existing not-found semantics so callers treat an unminted/absent MST token the + * same as a missing codex token. + * - other non-ok / network failure: throws a clear error (NOT swallowed here — the + * downstream consumer try/catches and fail-softs to imageless). + * - 200: parses JSON and maps to the plain MetadataDocument interface, defensively. + */ +export async function fetchMstMetadata( + contract: string, + tokenId: string +): Promise { + const url = mstMetadataUrl(tokenId); + + let res: Response; + try { + res = await fetch(url); + } catch (cause) { + throw new Error( + `MST metadata fetch failed for token ${tokenId} (${url}): ${String(cause)}` + ); + } + + if (!res.ok) { + // Unminted tokens return 403; absent return 404. Any 4xx => not-found semantics. + if (res.status >= 400 && res.status < 500) { + throw new NotFoundError(tokenId, contract); + } + throw new Error( + `MST metadata fetch returned HTTP ${res.status} for token ${tokenId} (${url})` + ); + } + + let json: { + name?: unknown; + description?: unknown; + image?: unknown; + attributes?: unknown; + }; + try { + json = (await res.json()) as typeof json; + } catch (cause) { + throw new Error( + `MST metadata JSON parse failed for token ${tokenId} (${url}): ${String(cause)}` + ); + } + + return { + name: typeof json.name === "string" ? json.name : "", + description: typeof json.description === "string" ? json.description : "", + image: typeof json.image === "string" ? json.image : "", + attributes: mapAttributes(json.attributes), + }; +} diff --git a/tests/getNftMetadata.mst.test.ts b/tests/getNftMetadata.mst.test.ts new file mode 100644 index 0000000..cdec8b5 --- /dev/null +++ b/tests/getNftMetadata.mst.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { getNftMetadata } from '../src/inventory.js'; +import { mstMetadataUrl } from '../src/sovereign-metadata.js'; +import { NotFoundError } from '../src/errors.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PKG_ROOT = path.resolve(__dirname, '..'); + +// Mibera Shadow (MST) contract — chain 80094. Input as all-lowercase on purpose: +// the resolver MUST checksum it before the registry lookup (EIP-55-consistent), +// not raw-case string compare. +const MST_CONTRACT = '0x048327a187b944ddac61c6e202bfccd20d17c008'; +const TOKEN_ID = '234'; + +const mstFixture = JSON.parse( + readFileSync(path.join(PKG_ROOT, 'fixtures/mst-token-234.json'), 'utf-8') +); + +/** + * Hermetic: global fetch is mocked so the suite stays fully offline. The MST path + * is the ONLY getNftMetadata branch that touches the network (sovereign storage-api); + * Mibera-main stays fixture-backed and fetch-free. + */ +describe('getNftMetadata — Mibera Shadow (MST) sovereign path', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resolves sovereign image + non-empty attributes for a minted token', async () => { + vi.stubGlobal('fetch', async (url: string) => { + // Confirms the resolver hits the sovereign storage-api route, not chain/sonar. + expect(url).toBe(mstMetadataUrl(TOKEN_ID)); + return new Response(JSON.stringify(mstFixture), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + const doc = await getNftMetadata(MST_CONTRACT, TOKEN_ID); + + expect(doc.image).toBe( + 'https://assets.0xhoneyjar.xyz/Mibera/generated/234.webp' + ); + expect(Array.isArray(doc.attributes)).toBe(true); + expect(doc.attributes.length).toBeGreaterThan(0); + // attributes coerced to {trait_type, value} string pairs + for (const attr of doc.attributes) { + expect(typeof attr.trait_type).toBe('string'); + expect(typeof attr.value).toBe('string'); + } + const bg = doc.attributes.find((a) => a.trait_type === 'background'); + expect(bg?.value).toBe('no more walls'); + expect(doc.name).toBe('MST #234'); + }); + + it('returns the plain MetadataDocument shape (no extra keys)', async () => { + vi.stubGlobal('fetch', async () => + new Response(JSON.stringify(mstFixture), { status: 200 }) + ); + + const doc = await getNftMetadata(MST_CONTRACT, TOKEN_ID); + expect(Object.keys(doc).sort()).toEqual( + ['attributes', 'description', 'image', 'name'].sort() + ); + }); + + it('throws NotFoundError on 403 (unminted token)', async () => { + vi.stubGlobal('fetch', async () => new Response('', { status: 403 })); + + await expect( + getNftMetadata(MST_CONTRACT, '999999') + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it('throws NotFoundError on 404', async () => { + vi.stubGlobal('fetch', async () => new Response('', { status: 404 })); + + const err = await getNftMetadata(MST_CONTRACT, '999999').catch((e) => e); + expect(err).toBeInstanceOf(NotFoundError); + expect(err.tokenId).toBe('999999'); + }); + + it('throws (does NOT swallow) on a 500 so the caller can fail-soft', async () => { + vi.stubGlobal('fetch', async () => new Response('', { status: 500 })); + + const err = await getNftMetadata(MST_CONTRACT, TOKEN_ID).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(NotFoundError); + }); +});