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
26 changes: 26 additions & 0 deletions fixtures/mst-token-234.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
26 changes: 25 additions & 1 deletion src/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, MetadataStrategy> = {
[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");
Expand Down Expand Up @@ -156,11 +170,21 @@ export async function getNftMetadata(
contract: string,
tokenId: string
): Promise<MetadataDocument> {
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);
Expand Down
95 changes: 95 additions & 0 deletions src/sovereign-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<MetadataDocument> {
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),
};
}
93 changes: 93 additions & 0 deletions tests/getNftMetadata.mst.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading