diff --git a/product-sdk/examples/nfts-demo/e2e/catalogue.spec.ts b/product-sdk/examples/nfts-demo/e2e/catalogue.spec.ts
index 7dea2f75..2ff70ad5 100644
--- a/product-sdk/examples/nfts-demo/e2e/catalogue.spec.ts
+++ b/product-sdk/examples/nfts-demo/e2e/catalogue.spec.ts
@@ -26,6 +26,8 @@ import { numberIn, waitForAppReady } from "./helpers";
* - getCollections paged -> a small-page walk pinned with `at`, cross-checked against a
* single larger page
* - getCollectionItems -> Scarcity.ItemDefs/ItemMetadata prefix scans, merged metadata
+ * - getInstanceDisplays -> ScarcityApi.metadata_batch + a keyed Scarcity.ItemDefs read;
+ * minted NFTs rather than a catalogue, `Found` / `NotFound` per id
* - the structural chain contract, satisfied by a real ChainClient
*/
test.describe("@parity/product-sdk-nfts via Host API, catalogue reads", () => {
@@ -193,6 +195,23 @@ test.describe("@parity/product-sdk-nfts via Host API, catalogue reads", () => {
await expect(frame.locator('[data-testid="nfts-log"]')).toContainText("previewClaim:");
});
+ test("minted instances answer per id, and u64 max is a clean miss", async ({ testHost }) => {
+ const frame = await waitForAppReady(testHost);
+
+ const found = frame.locator('[data-testid="instances-found"]');
+ await expect(found).not.toHaveText("-", { timeout: 60_000 });
+ // How many of the probed low ids are minted is live state, so only the
+ // shape is pinned: four asked for, the fourth one that cannot exist.
+ await expect(found).toHaveText(/^\d+ of 4$/);
+
+ // u64 max was never allocated, so this is the `NotFound` arm of the
+ // same read, on the ok channel rather than as an error.
+ await expect(frame.locator('[data-testid="instance-missing-tag"]')).toHaveText("NotFound");
+ await expect(frame.locator('[data-testid="nfts-log"]')).toContainText(
+ "getInstanceDisplays:",
+ );
+ });
+
test("the artwork of the first item is fetched and checked against its reference", async ({
testHost,
}) => {
diff --git a/product-sdk/examples/nfts-demo/index.html b/product-sdk/examples/nfts-demo/index.html
index 1c92f761..00634da8 100644
--- a/product-sdk/examples/nfts-demo/index.html
+++ b/product-sdk/examples/nfts-demo/index.html
@@ -80,6 +80,15 @@
Catalogue (getCollectionItems)
A collection nobody created
u32 max reads as: -
+ Minted instances (getInstanceDisplays)
+ Asked for: -
+ Found: -
+ Resolved to collection/item: -
+ First found: -
+ Transferability: -
+ Metadata keys: -
+ u64 max reads as: -
+
Credits (getCredits)
People block: -
Count: -
diff --git a/product-sdk/examples/nfts-demo/src/main.ts b/product-sdk/examples/nfts-demo/src/main.ts
index ced07444..14d517fd 100644
--- a/product-sdk/examples/nfts-demo/src/main.ts
+++ b/product-sdk/examples/nfts-demo/src/main.ts
@@ -3,9 +3,9 @@
/**
* Entry point for the @parity/product-sdk-nfts E2E demo.
*
- * All three reads are pure catalogue, so there is no signer here: they need a
- * chain client and nothing else. The client comes from the host over the
- * container chain API, the same path the other demos take.
+ * Every read here is a read, so there is no signer: they need a chain client
+ * and nothing else. The client comes from the host over the container chain
+ * API, the same path the other demos take.
*
* Flow inside the host-api-test-sdk test host:
* 1. createChainClient({ chains: { assetHub } }) connects via the host
@@ -16,6 +16,10 @@
* 4. getCollectionItems(chain, id) -> the catalogue of that collection
* 5. getCollectionItems(chain, MISSING_COLLECTION) -> `NotFound` on the ok
* channel, which is the part of the contract worth seeing in a UI
+ * 5b. getInstanceDisplays(chain, [...PROBE_INSTANCES, MISSING_INSTANCE]) ->
+ * minted NFTs by instance id rather than a catalogue, each `Found` or
+ * `NotFound` on its own, with the (collection, item) the runtime resolved
+ * and the transferability that decides whether a send may be offered
* 6. getClaims(chain, { claimant }) -> every credit one account holds, read
* across the People chain and Asset Hub at one pinned block each
* 7. previewClaim(chain, { credit, collections }) -> what that credit would
@@ -39,6 +43,7 @@ import {
getClaimableCollections,
getCollectionItems,
getClaims,
+ getInstanceDisplays,
getVerifiedArtwork,
gatewaySource,
previewClaim,
@@ -72,6 +77,13 @@ const $itemSupply = getEl("item-supply");
const $itemImageHex = getEl("item-image-hex");
const $itemImageText = getEl("item-image-text");
const $missingTag = getEl("missing-tag");
+const $instancesAsked = getEl("instances-asked");
+const $instancesFound = getEl("instances-found");
+const $instancesTargets = getEl("instances-targets");
+const $instanceName = getEl("instance-name");
+const $instanceTransferability = getEl("instance-transferability");
+const $instanceAttributes = getEl("instance-attributes");
+const $instanceMissingTag = getEl("instance-missing-tag");
const $creditsBlock = getEl("credits-block");
const $creditsCount = getEl("credits-count");
const $creditsStates = getEl("credits-states");
@@ -89,6 +101,19 @@ function log(msg: string, level: Parameters[2] = "info"): void
/** No `Scarcity.Collections` record can exist at u32 max, so this is always a miss. */
const MISSING_COLLECTION = 4_294_967_295;
+/**
+ * Instance ids to probe, and one that cannot exist.
+ *
+ * The demo has no purses and no owner, which is the honest situation for this
+ * read: where ids come from is the caller's business, and the package does not
+ * enumerate them. Instances are allocated from zero and never reused, so the
+ * low ids are the ones a live chain is most likely to have minted; each is
+ * reported `Found` or `NotFound` on its own. u64 max cannot have been
+ * allocated, so it pins the miss case whatever the chain holds.
+ */
+const PROBE_INSTANCES = [0n, 1n, 2n];
+const MISSING_INSTANCE = 18_446_744_073_709_551_615n;
+
let chain: ChainClient<{
assetHub: typeof paseo_asset_hub;
individuality: typeof paseo_individuality;
@@ -306,6 +331,60 @@ async function readPreview(collections: number[], credit: string): Promise
log(`previewClaim: ${previews.length} outcomes for ${credit.slice(0, 10)}…`, "ok");
}
+/**
+ * Minted NFTs by instance id, the one read here that is not about a catalogue.
+ *
+ * Positional: one answer per id asked, in order, each `Found` or `NotFound`.
+ * The pair worth seeing in a UI is the last two fields: a `Found` instance
+ * whose `attributes` bag is empty is a real minted NFT that carries no
+ * metadata, which is every claim-minted one today, and that is a different
+ * statement from the `NotFound` that u64 max returns.
+ */
+async function readInstances(): Promise {
+ if (!chain) return;
+ const asked = [...PROBE_INSTANCES, MISSING_INSTANCE];
+ $instancesAsked.textContent = asked.map((i) => i.toString()).join(",");
+
+ const result = await getInstanceDisplays(chain, asked);
+ if (!result.ok) {
+ $instancesFound.textContent = "error";
+ log(`getInstanceDisplays failed: ${describeError(result.error)}`, "err");
+ return;
+ }
+
+ const { at, displays } = result.value;
+ const found = displays.filter((d) => d.tag === "Found");
+ $instancesFound.textContent = `${found.length} of ${displays.length}`;
+ $instancesTargets.textContent =
+ found.map((d) => `${d.instance}→${d.collection}/${d.item}`).join(", ") || "(none)";
+ // The last answer is MISSING_INSTANCE, and it is a success value.
+ $instanceMissingTag.textContent = displays[displays.length - 1]?.tag ?? "-";
+
+ const first = found[0];
+ if (first === undefined) {
+ $instanceName.textContent = "(no instance minted at these ids)";
+ log(`getInstanceDisplays: nothing minted at ${asked.length - 1} probed ids`, "info");
+ return;
+ }
+ // The item's own name, then its collection's — never merged, so an item
+ // titled by `archetype` does not come back wearing its collection's name.
+ const title = first.name ?? first.collectionName ?? "(unnamed)";
+ // Null here means the item definition is gone from under a live instance,
+ // not that the read skipped it.
+ const transferability = first.transferability ?? "(definition gone)";
+ $instanceName.textContent = title;
+ $instanceTransferability.textContent = transferability;
+ // Always filled, unlike a catalogue page: all three layers merged.
+ $instanceAttributes.textContent = Object.keys(first.attributes).join(",") || "(none)";
+
+ log(
+ `getInstanceDisplays: ${found.length} found at #${at.blockNumber}, ` +
+ `first is ${title} from collection ${first.collection} ` +
+ `item ${first.item} (${transferability})`,
+ "ok",
+ );
+}
+
async function read(): Promise {
if (!chain) return;
$btnRefresh.disabled = true;
@@ -341,6 +420,8 @@ async function read(): Promise {
const missing = await getCollectionItems(chain, MISSING_COLLECTION, { limit: 1 });
$missingTag.textContent = missing.ok ? missing.value.tag : "error";
+ await readInstances();
+
const credit = (await readCredits()) ?? FALLBACK_CREDIT;
await readPreview(
collections.map((c) => c.id),
@@ -385,12 +466,14 @@ declare global {
getCollections: typeof getCollections;
getCollectionItems: typeof getCollectionItems;
getClaims: typeof getClaims;
+ getInstanceDisplays: typeof getInstanceDisplays;
previewClaim: typeof previewClaim;
readonly chain: ChainClient<{
assetHub: typeof paseo_asset_hub;
individuality: typeof paseo_individuality;
}> | null;
MISSING_COLLECTION: number;
+ MISSING_INSTANCE: bigint;
};
}
}
@@ -400,11 +483,13 @@ window.__NFTS__ = {
getCollections,
getCollectionItems,
getClaims,
+ getInstanceDisplays,
previewClaim,
get chain() {
return chain;
},
MISSING_COLLECTION,
+ MISSING_INSTANCE,
};
init().catch((err) => log(`Unhandled init error: ${(err as Error).message}`, "err"));
diff --git a/product-sdk/packages/nfts/src/chain.ts b/product-sdk/packages/nfts/src/chain.ts
index 319e5ccf..977a1b6d 100644
--- a/product-sdk/packages/nfts/src/chain.ts
+++ b/product-sdk/packages/nfts/src/chain.ts
@@ -74,6 +74,8 @@ import type {
RawCollection,
RawItemDef,
RawMetadataEntry,
+ RawMetadataLayers,
+ RawMetadataQuery,
} from "./types.js";
import type {
Claimant,
@@ -254,6 +256,92 @@ export interface NftsChain {
};
}
+/**
+ * The two entries a display read of minted instances needs, beside the raw
+ * client it pins a block with.
+ *
+ * Separate from {@link NftsChain} for the same reason {@link NftsCreditsChain}
+ * is: a read should never ask for a surface it does not touch. An app that
+ * prunes its descriptors to the catalogue entries can keep calling the
+ * catalogue reads; only `getInstanceDisplays` demands the runtime API.
+ *
+ * This is the second runtime API in the package, and the bar it had to clear
+ * is written on the first (`preview_mints`, in the index doc): no storage
+ * equivalent. Storage *can* answer an instance's metadata, but not reasonably.
+ * `InstanceMetadata` keys on the instance id, while the item and collection
+ * layers key on `(collection, item)` — and the only storage path from an
+ * instance to its item runs through two serial owner-keyed hops,
+ * `Scarcity.Instances` (instance → owner) then `Scarcity.NftsByOwner`
+ * (owner → NFT record), before three metadata reads can even be addressed.
+ * `metadata_batch` is the pallet's own answer: one runtime call takes a batch
+ * of targets and returns, positionally, every metadata pair of all three
+ * layers plus the resolved (collection, item) of each target. One operation
+ * for a whole shelf, against five per instance with two of them serial.
+ *
+ * The call refuses an oversized batch outright — `TooLarge`, carrying the cap
+ * it would have accepted — rather than truncating. On live
+ * `next-asset-hub-paseo` the cap is 128 queries; the read chunks below it and
+ * takes the runtime's word when a deployment configures less.
+ *
+ * `ItemDefs` is the second entry, and it is read always rather than behind an
+ * option because its cost is bounded by the question asked. Whether an
+ * instance is soulbound, and how many of its item exist, live on the item
+ * definition rather than in metadata, so `metadata_batch` cannot answer them.
+ * The keys are the `(collection, item)` pairs `metadata_batch` just resolved,
+ * deduplicated — every instance of one definition shares a key — so this costs
+ * one serial hop and bytes under its own input. A different trade from
+ * `attributes` on a catalogue page, whose prefix scan costs bytes proportional
+ * to the whole collection and so is opt-in there.
+ *
+ * Matched by hand on 2026-10-01 against the same pinned descriptors as
+ * {@link NftsChain}:
+ *
+ * ```
+ * ScarcityApi.metadata_batch(queries) -> Result, TooLarge { max }>
+ * Scarcity.ItemDefs map (u32, u32) -> { supply, live_supply, transferability }
+ * ```
+ */
+export interface NftsInstancesChain {
+ assetHub: {
+ query: {
+ Scarcity: {
+ /**
+ * The definitions behind a set of instances, by exact
+ * `(collection, item)` key.
+ *
+ * The same entry and the same call shape a catalogue page
+ * uses; only the keys differ, coming from what
+ * `metadata_batch` resolved rather than from an index window.
+ */
+ ItemDefs: {
+ getValues(
+ keys: Array<[number, number]>,
+ options: ReadAt,
+ ): Promise>;
+ };
+ };
+ };
+ apis: {
+ ScarcityApi: {
+ /**
+ * All metadata of a batch of targets, answered positionally:
+ * `out[i]` answers `queries[i]`, and a target that does not
+ * exist answers with no `resolved` rather than an error.
+ */
+ metadata_batch(
+ queries: RawMetadataQuery[],
+ options: ReadAt,
+ ): Promise<
+ RuntimeResult
+ >;
+ };
+ };
+ };
+ raw: {
+ assetHub: BlockSource;
+ };
+}
+
/**
* The second chain a credits read needs, beside {@link NftsChain}.
*
diff --git a/product-sdk/packages/nfts/src/errors.ts b/product-sdk/packages/nfts/src/errors.ts
index fe0b5a78..7de6570b 100644
--- a/product-sdk/packages/nfts/src/errors.ts
+++ b/product-sdk/packages/nfts/src/errors.ts
@@ -61,18 +61,27 @@ export class NftsDecodeError extends ProductNftsError {
* Collection ids and item indices are `u32`, and the PAPI codec truncates
* rather than rejecting, so an unchecked `NaN` or `1.5` would read a real
* collection and report it under the id the caller asked for. Refusing is the
- * only answer that cannot be mistaken for a catalogue.
+ * only answer that cannot be mistaken for a catalogue. Instance ids are `u64`
+ * and already integers as `bigint`s, but the range hazard is the same, so an
+ * out-of-range one is refused through the same class.
+ *
+ * Which space the id missed follows from its type — collection ids and item
+ * indices are `number`, instance ids are `bigint` — so the constructor keeps
+ * the `(id, options?)` shape and cannot be handed a space that contradicts
+ * the value.
*
* The message carries the value because it is caller input, not chain content.
* The rule on {@link NftsDecodeError} is about author-supplied metadata.
*/
export class NftsIdError extends ProductNftsError {
/** The value that could not address anything. */
- readonly id: number;
+ readonly id: number | bigint;
- constructor(id: number, options?: ErrorOptions) {
+ constructor(id: number | bigint, options?: ErrorOptions) {
super(
- `Collection id ${id} is not a u32. Ids and item indices are whole numbers from 0 to 2^32 - 1.`,
+ typeof id === "bigint"
+ ? `Instance id ${id} is not a u64. Instance ids are whole numbers from 0 to 2^64 - 1.`
+ : `Collection id ${id} is not a u32. Ids and item indices are whole numbers from 0 to 2^32 - 1.`,
options,
);
this.name = "NftsIdError";
@@ -170,6 +179,18 @@ if (import.meta.vitest) {
const cause = new Error("underlying");
expect(new NftsDecodeError("boom", { cause }).cause).toBe(cause);
});
+
+ test("NftsIdError derives the id space from the value's type", () => {
+ // A bigint is an instance id and a number a collection id or item
+ // index, by the signatures of every read, so the message cannot be
+ // made to contradict the value — and `options` stays the second
+ // parameter, the shape every error here shares.
+ const cause = new Error("underlying");
+ const instance = new NftsIdError(1n << 64n, { cause });
+ expect(instance.message).toContain("u64");
+ expect(instance.cause).toBe(cause);
+ expect(new NftsIdError(Number.NaN).message).toContain("u32");
+ });
});
describe("matchChainEntryError", () => {
diff --git a/product-sdk/packages/nfts/src/index.ts b/product-sdk/packages/nfts/src/index.ts
index 5bb5a482..f2e0dc6d 100644
--- a/product-sdk/packages/nfts/src/index.ts
+++ b/product-sdk/packages/nfts/src/index.ts
@@ -3,10 +3,13 @@
/**
* @parity/product-sdk-nfts reads Scarcity NFT collections and item catalogues on Asset Hub.
*
- * Three reads today, all of them pure catalogue and all of them paged: which
- * collections a claim can mint into, every collection whether it accepts
- * claims or not, and what is in one of them. None needs an identity, a purse, or a second chain,
- * which is why they came first.
+ * Three catalogue reads, all of them paged: which collections a claim can mint
+ * into, every collection whether it accepts claims or not, and what is in one
+ * of them. None needs an identity, a purse, or a second chain, which is why
+ * they came first. Beside them, `getInstanceDisplays` describes *minted* NFTs
+ * by instance id — what somebody holds rather than what a collection defines.
+ * It is instance-keyed, so it too needs no purse: the caller says which
+ * instances, however it learned of them.
*
* `getClaimableCollections` and `getCollections` are the subset and the
* superset of the same thing. There is one kind of collection, and a
@@ -14,12 +17,19 @@
* reads a page, so pick by which set you want. Prefer the registry read when
* only claimable collections belong in the answer.
*
- * **Every read is paged, and none of them is unbounded.** `limit` defaults to
- * {@link DEFAULT_PAGE_LIMIT} and caps at {@link MAX_PAGE_LIMIT}; there is no
- * "give me everything", because nothing bounds how many collections exist or how
- * many items a collection holds. The only ceilings the pallet has are index-space
- * exhaustion, and the indices are `u32`. Follow `nextId` to walk the
- * whole of anything, in bounded pieces.
+ * **Every catalogue read is paged, and none of them is unbounded.** `limit`
+ * defaults to {@link DEFAULT_PAGE_LIMIT} and caps at {@link MAX_PAGE_LIMIT};
+ * there is no "give me everything", because nothing bounds how many collections
+ * exist or how many items a collection holds. The only ceilings the pallet has
+ * are index-space exhaustion, and the indices are `u32`. Follow `nextId` to
+ * walk the whole of anything, in bounded pieces.
+ *
+ * The positional reads, `previewClaim` and `getInstanceDisplays`, are bounded
+ * by their input instead: they answer exactly the list they were given, in its
+ * order. `getInstanceDisplays` caps that list at
+ * {@link MAX_INSTANCES_PER_READ} and refuses above it rather than paging,
+ * because the list is the caller's own and a cursor would page someone over
+ * their own input.
*
* One vocabulary across all three reads: `limit` and `fromId` in, `idCeiling` and
* `nextId` out, so a single pager works against any of them.
@@ -31,7 +41,11 @@
*
* ```ts
* import { getChainAPI } from "@parity/product-sdk-chain-client";
- * import { getClaimableCollections, getCollectionItems } from "@parity/product-sdk-nfts";
+ * import {
+ * getClaimableCollections,
+ * getCollectionItems,
+ * getInstanceDisplays,
+ * } from "@parity/product-sdk-nfts";
*
* const chain = await getChainAPI("paseo");
*
@@ -49,6 +63,16 @@
* if (catalogue.ok && catalogue.value.tag === "Found") {
* console.log(catalogue.value.collection.items, catalogue.value.nextId);
* }
+ *
+ * // Minted NFTs by instance id, answered in the order asked. Not paged: the
+ * // list is the bound. `NotFound` is an instance nobody minted, or a burned one.
+ * const shelf = await getInstanceDisplays(chain, [0n, 1n, 2n]);
+ * if (shelf.ok) {
+ * for (const display of shelf.value.displays) {
+ * if (display.tag === "NotFound") continue;
+ * console.log(display.name ?? "(unnamed)", display.collection, display.transferability);
+ * }
+ * }
* ```
*
* Failures arrive on the `err` channel as a {@link ProductNftsError}, per the
@@ -76,7 +100,9 @@
* query.Scarcity.ItemMetadata query.NftClaims.CollectionMinters
* ```
*
- * `previewClaim` adds one runtime API, `api.NftClaimsApi.preview_mints`, and
+ * `previewClaim` adds one runtime API, `api.NftClaimsApi.preview_mints`.
+ * `getInstanceDisplays` touches two entries, `api.ScarcityApi.metadata_batch`
+ * and `query.Scarcity.ItemDefs`, the second shared with the catalogue reads.
* `getClaims` adds two Asset Hub entries and four on the People chain:
*
* ```
@@ -98,11 +124,14 @@
*
* # What this package deliberately does not do yet
*
- * - **One runtime API, `preview_mints`, and no others.** Display metadata is
- * read from the `CollectionMetadata` / `ItemMetadata` storage layers, which
+ * - **Two runtime APIs, and both earned it.** Display metadata of a *catalogue*
+ * is read from the `CollectionMetadata` / `ItemMetadata` storage layers, which
* answer the same question and are carried by the pinned descriptor.
- * `previewClaim` has no storage equivalent, so it is the exception, and the
- * fidelity guard in `@parity/product-sdk` checks its signature against the
+ * `previewClaim` has no storage equivalent, so `preview_mints` is one
+ * exception; `metadata_batch` is the other, because storage answers an
+ * *instance's* metadata only through two serial owner-keyed hops before the
+ * three layers can even be addressed (see `NftsInstancesChain`). The fidelity
+ * guard in `@parity/product-sdk` checks both signatures against the
* descriptor the same way it checks the storage entries.
* - **`attributes` costs a prefix scan of the whole collection.** The typed
* fields are keys this package can name, so a page fetches them for its window
@@ -122,7 +151,9 @@
* `findPurseHolding` all need a purse primitive shared across apps, which the
* wallet does not expose yet. App-scoped product-account derivation is not a
* substitute: it is keyed by `productId`, so nothing derived under it can be
- * shared between two SPAs.
+ * shared between two SPAs. `getInstanceDisplays` already answers the display
+ * half of an owned read — once a purse primitive can enumerate what is held,
+ * the owned read composes on it rather than growing its own metadata path.
* - **Metadata keys are a convention, not a contract.** Nothing in the runtime
* declares them. `name`, `image` and `rarity` are lifted into typed fields
* because every deployment read so far carries them; the rest of the bag is
@@ -153,6 +184,16 @@ export type { GetClaimsOptions } from "./claims.js";
export { previewClaim } from "./preview.js";
export type { PreviewClaimOptions } from "./preview.js";
+// The display metadata of minted NFTs by instance id, positional like
+// `previewClaim`. A missing instance is `NotFound` on the ok channel; an
+// existing one with no metadata is `Found` with an empty bag. Capped rather
+// than paged, because the list of instances is the caller's own.
+export {
+ getInstanceDisplay,
+ getInstanceDisplays,
+ MAX_INSTANCES_PER_READ,
+} from "./instances.js";
+
// The bytes an image reference names, and only when they hash to it. No chain
// read: the source is the caller's, the check is this package's.
export { artworkAddress, gatewaySource, getVerifiedArtwork, preimageSource } from "./artwork.js";
@@ -168,9 +209,15 @@ export type {
// may read before it comes back short.
export { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, SCAN_BUDGET_FACTOR } from "./paging.js";
-// The chain contract both reads take: the storage entries and the raw client
+// The chain contracts the reads take: the entries each needs and the raw client
// they pin with, structural so no genesis hash is pinned to read a catalogue.
-export type { BlockSource, Entry, NftsChain, NftsCreditsChain } from "./chain.js";
+export type {
+ BlockSource,
+ Entry,
+ NftsChain,
+ NftsCreditsChain,
+ NftsInstancesChain,
+} from "./chain.js";
// `NftsChainEntryError` is the one worth narrowing on: it means the client
// cannot read an entry this package needs, which no retry will fix.
@@ -186,10 +233,9 @@ export {
// the primitives to assemble them from. `decodeMetadataValue` collapses bytes to
// one reading, `imageRefFrom` needs raw layers in precedence order, and
// `mergeMetadata` is one `Object.assign`; handing those out asks the caller to
-// re-derive the layering and the text/bytes question we already answered. When
-// a read of `InstanceMetadata` lands, and the entry is already in the descriptors,
-// it just describes minted NFTs rather than a catalogue, it should arrive as a
-// read returning finished shapes, not as three exported helpers.
+// re-derive the layering and the text/bytes question we already answered. The
+// read of instance metadata arrived exactly this way: `getInstanceDisplays`
+// returns finished shapes, and the helpers stayed inside.
// The shapes the reads return, and the raw storage shapes behind them.
export type {
@@ -204,6 +250,9 @@ export type {
BlockSnapshot,
FinalizedSnapshot,
ImageRef,
+ InstanceDisplay,
+ InstanceDisplayResult,
+ InstanceDisplaysResult,
ItemSelection,
MintPreview,
MintPreviewResult,
@@ -219,6 +268,9 @@ export type {
RawItemDef,
Transferability,
RawMetadataEntry,
+ RawMetadataLayers,
+ RawMetadataQuery,
+ RawMetadataTarget,
RawMinter,
ReadAt,
RuntimeResult,
diff --git a/product-sdk/packages/nfts/src/instances.ts b/product-sdk/packages/nfts/src/instances.ts
new file mode 100644
index 00000000..06c8f9db
--- /dev/null
+++ b/product-sdk/packages/nfts/src/instances.ts
@@ -0,0 +1,882 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: Apache-2.0
+/**
+ * `getInstanceDisplays`, the display metadata of minted NFTs by instance id.
+ *
+ * The catalogue reads describe what a collection *defines*; this one describes
+ * what somebody *holds*. An instance carries three metadata layers — its
+ * collection's defaults, its item's overrides, and its own — and the pallet
+ * resolves them most-specific-first (`instance_metadata_of`). The read asks the
+ * runtime to do exactly that through `ScarcityApi.metadata_batch`, which also
+ * answers which (collection, item) each instance was minted from, so a shelf
+ * can group what it shows without a second lookup.
+ *
+ * Positional like `previewClaim`: `displays[i]` answers `instances[i]`. An
+ * instance nobody minted, or one already burned, is a `NotFound` display rather
+ * than an error — the runtime was asked and answered. The signal is the runtime
+ * declining to resolve the query, **not** an empty metadata bag: a freshly
+ * claim-minted instance carries no metadata at all on the live chain and is
+ * still `Found`, with `null` typed fields and an empty bag.
+ *
+ * At most {@link MAX_INSTANCES_PER_READ} instances per call. The read is not
+ * paged and does not need to be: every other read here walks a space the chain
+ * sizes, while this one answers a list the caller already holds, so a cursor
+ * would page someone over their own input. Above the cap it refuses and says
+ * to split.
+ *
+ * On a deployment whose `metadata_batch` cap is at least that, which is the
+ * live one, a full read is a single runtime call. `metadata_batch` refuses an
+ * oversized batch outright rather than truncating, so a deployment configured
+ * lower reports its cap in the refusal and the read re-chunks to it once
+ * before giving up.
+ *
+ * Then one `ItemDefs` read, keyed by the `(collection, item)` pairs the batch
+ * resolved and deduplicated, fills in `transferability`, `supply` and
+ * `liveSupply`. Metadata cannot answer those, and whether an instance is
+ * soulbound decides whether a UI may offer to send it, so it is read always
+ * rather than behind a flag: the keys come from the answer already in hand, so
+ * it costs one serial hop and nothing per instance beyond what was asked.
+ */
+import { err, normalizeError, ok, type Result } from "@parity/result";
+import { pinAt, readAt, type NftsInstancesChain } from "./chain.js";
+import { matchChainEntryError, NftsDecodeError, NftsIdError, ProductNftsError } from "./errors.js";
+import { decodeBag, toTransferability } from "./items.js";
+import { decodeMetadataKey, imageRefFrom, mergeMetadata } from "./metadata.js";
+import type {
+ InstanceDisplay,
+ InstanceDisplayResult,
+ InstanceDisplaysResult,
+ PinnedReadOptions,
+ RawBytes,
+ RawItemDef,
+ RawMetadataLayers,
+ ReadAt,
+} from "./types.js";
+
+/**
+ * The most instances one read answers for, and the size of a chunk.
+ *
+ * **This read is not paged, and this is why it does not need to be.** Every
+ * other read here walks a space the chain decides the size of, so it hands
+ * back a cursor. The instances are the caller's own list, so a cursor would
+ * page someone over their own input; a cap and a refusal say the same thing
+ * without the ceremony. Over the cap, split the list.
+ *
+ * 128 is this package's contract, not a reading of the chain. It coincides
+ * with the batch cap live `next-asset-hub-paseo` configures for
+ * `metadata_batch`, which is why a full read is one runtime call there — but
+ * that cap is deployment configuration and this number must not be made to
+ * track it. A deployment that allows fewer refuses with `TooLarge` carrying
+ * its own cap, and the read re-chunks to that; a deployment that allows more
+ * changes nothing here.
+ *
+ * Deliberately near {@link DEFAULT_PAGE_LIMIT} rather than
+ * {@link MAX_PAGE_LIMIT}: a shelf is tens of NFTs, and a cap is a minor
+ * change to raise and a breaking one to lower.
+ */
+export const MAX_INSTANCES_PER_READ = 128;
+
+const U64_CEILING = 1n << 64n;
+
+/**
+ * The display metadata of many minted instances, in the order asked.
+ *
+ * At most {@link MAX_INSTANCES_PER_READ} instances, refused above that rather
+ * than paged: the list is the caller's, so splitting it is the caller's to do.
+ * One pinned block, one `metadata_batch` call, then one keyed `ItemDefs` read
+ * for the definitions behind whatever resolved. An empty list is an empty
+ * answer and no round trip past the pin. Duplicate ids are answered per
+ * position, not deduplicated.
+ *
+ * @example
+ * ```ts
+ * const chain = await getChainAPI("paseo");
+ * const result = await getInstanceDisplays(chain, [1n, 2n, 404n]);
+ * if (result.ok) {
+ * for (const display of result.value.displays) {
+ * if (display.tag === "NotFound") continue; // burned, or never minted
+ * console.log(display.instance, display.name ?? "(unnamed)", display.rarity);
+ * // Offer a send only for an instance the runtime will let move.
+ * if (display.transferability === "Transferable") renderSendButton(display);
+ * }
+ * }
+ * ```
+ */
+export async function getInstanceDisplays(
+ chain: NftsInstancesChain,
+ instances: bigint[],
+ options: PinnedReadOptions = {},
+): Promise> {
+ try {
+ if (instances.length > MAX_INSTANCES_PER_READ) {
+ return err(
+ new ProductNftsError(
+ `getInstanceDisplays answers at most ${MAX_INSTANCES_PER_READ} instances per call, asked for ${instances.length}. Split the list; this read is not paged because the list is yours to page.`,
+ ),
+ );
+ }
+ for (const instance of instances) {
+ if (instance < 0n || instance >= U64_CEILING) {
+ return err(new NftsIdError(instance));
+ }
+ }
+ const snapshot = await pinAt(chain.raw.assetHub, options.signal, options.at);
+ if (instances.length === 0) return ok({ at: snapshot, displays: [] });
+ const at = readAt(snapshot, options.signal);
+ const layers = await readBatched(chain, instances, at);
+ const defs = await readItemDefs(chain, layers, at);
+ return ok({
+ at: snapshot,
+ displays: layers.map((answer, i) => toDisplay(instances[i] as bigint, answer, defs)),
+ });
+ } catch (cause) {
+ return err(matchChainEntryError(cause) ?? normalizeError(cause, ProductNftsError));
+ }
+}
+
+/**
+ * The display metadata of one minted instance.
+ *
+ * `getInstanceDisplays` for a single id, for the flows that look one NFT up —
+ * a detail view, a transfer confirmation. Reading a shelf one instance at a
+ * time through this forfeits the batching; pass the list instead.
+ */
+export async function getInstanceDisplay(
+ chain: NftsInstancesChain,
+ instance: bigint,
+ options: PinnedReadOptions = {},
+): Promise> {
+ const result = await getInstanceDisplays(chain, [instance], options);
+ if (!result.ok) return result;
+ const display = result.value.displays[0];
+ if (display === undefined) {
+ return err(new NftsDecodeError("metadata_batch answered a different number of queries"));
+ }
+ return ok({ at: result.value.at, display });
+}
+
+/**
+ * Every chunk through `metadata_batch`, reassembled positionally.
+ *
+ * Chunks go out in parallel: each is one runtime operation, and a shelf's
+ * worth of instances is a few of them. A `TooLarge` refusal naming a cap below
+ * the one used re-chunks only the instances that refusal covered, at the
+ * runtime's number, once — the chunks that already answered are kept, and the
+ * second attempt is at the cap the runtime itself reported, so a second refusal
+ * is the runtime disagreeing with itself and surfaces as the error it is.
+ */
+async function readBatched(
+ chain: NftsInstancesChain,
+ instances: bigint[],
+ at: ReadAt,
+ limit: number = MAX_INSTANCES_PER_READ,
+ retried = false,
+): Promise {
+ const chunks: bigint[][] = [];
+ for (let i = 0; i < instances.length; i += limit) {
+ chunks.push(instances.slice(i, i + limit));
+ }
+ const results = await Promise.all(
+ chunks.map((chunk) =>
+ chain.assetHub.apis.ScarcityApi.metadata_batch(
+ chunk.map((instance) => ({ type: "Instance" as const, value: instance })),
+ at,
+ ),
+ ),
+ );
+ const answers: RawMetadataLayers[] = [];
+ for (const [index, result] of results.entries()) {
+ const chunk = chunks[index] ?? [];
+ if (!result.success) {
+ const refusal = result.value;
+ const max = refusal.value?.max;
+ if (!retried && typeof max === "number" && max > 0 && max < limit) {
+ // Re-chunk only this refused chunk at the runtime's cap; the
+ // chunks that answered keep their results.
+ answers.push(...(await readBatched(chain, chunk, at, max, true)));
+ continue;
+ }
+ // The variant and the numbers are runtime-reported, not
+ // author-supplied, so naming them is safe and the difference
+ // between "shrink the batch" and "this runtime is refusing".
+ const reported = typeof max === "number" ? max : "unreported";
+ const after = retried ? " after already re-chunking to the cap it reported" : "";
+ throw new ProductNftsError(
+ `metadata_batch refused a chunk of ${chunk.length} as too large (${refusal.type}, runtime max ${reported})${after}`,
+ );
+ }
+ if (result.value.length !== chunk.length) {
+ throw new NftsDecodeError("metadata_batch answered a different number of queries");
+ }
+ answers.push(...result.value);
+ }
+ return answers;
+}
+
+/** The key of one item definition, as a map key a `Map` can compare. */
+const defKey = (collection: number, item: number): string => `${collection}/${item}`;
+
+/**
+ * The item definitions behind whatever the batch resolved, in one read.
+ *
+ * Keyed by the `(collection, item)` pairs `metadata_batch` just reported, so
+ * nothing here scans and nothing is read that no instance points at. Pairs are
+ * deduplicated first: a shelf of one collection is usually a handful of
+ * definitions, and a key asked for twice is a storage operation spent twice.
+ *
+ * Serial after the metadata call by necessity — the keys are its answer. A
+ * definition missing from the map is a real state, an instance outliving its
+ * definition, and surfaces as `null` fields rather than an error.
+ */
+async function readItemDefs(
+ chain: NftsInstancesChain,
+ layers: RawMetadataLayers[],
+ at: ReadAt,
+): Promise