From 3473d2f020fdeb13a57c42e3537e9ec571ae8d75 Mon Sep 17 00:00:00 2001 From: Daria Mikhailova Date: Mon, 5 Oct 2026 22:41:38 +1300 Subject: [PATCH 1/2] feat(nfts): read the display metadata of minted NFTs by instance id getInstanceDisplays / getInstanceDisplay: positional per-instance reads of name, collectionName, rarity, imageRef, the three-layer attributes bag, the resolved (collection, item), and transferability/supply/liveSupply from the item definition. Capped at MAX_INSTANCES_PER_READ (128), refused above it rather than paged. name is the instance's own (instance layer, else item layer) and never inherits the collection's, which is reported beside it as collectionName. --- .../examples/nfts-demo/e2e/catalogue.spec.ts | 19 + product-sdk/examples/nfts-demo/index.html | 9 + product-sdk/examples/nfts-demo/src/main.ts | 91 +- product-sdk/packages/nfts/src/chain.ts | 88 ++ product-sdk/packages/nfts/src/errors.ts | 29 +- product-sdk/packages/nfts/src/index.ts | 98 +- product-sdk/packages/nfts/src/instances.ts | 878 ++++++++++++++++++ product-sdk/packages/nfts/src/items.ts | 50 +- product-sdk/packages/nfts/src/types.ts | 167 +++- .../packages/sdk/src/nfts/contract.test.ts | 13 +- .../nfts-instance-displays.md | 50 + product-sdk/skills/product-sdk-nfts/SKILL.md | 91 +- 12 files changed, 1544 insertions(+), 39 deletions(-) create mode 100644 product-sdk/packages/nfts/src/instances.ts create mode 100644 product-sdk/pending-changesets/nfts-instance-displays.md 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..74c5a130 --- /dev/null +++ b/product-sdk/packages/nfts/src/instances.ts @@ -0,0 +1,878 @@ +// 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-runs the whole batch at the runtime's number, once — 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()) { + if (!result.success) { + const refusal = result.value; + const max = refusal.value?.max; + if (!retried && typeof max === "number" && max > 0 && max < limit) { + return readBatched(chain, instances, at, max, true); + } + // 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 ${chunks[index]?.length ?? 0} as too large (${refusal.type}, runtime max ${reported})${after}`, + ); + } + if (result.value.length !== chunks[index]?.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> { + const keys: Array<[number, number]> = []; + const seen = new Set(); + for (const answer of layers) { + const resolved = answer.resolved; + if (resolved?.type !== "Instance") continue; + const { collection, item } = resolved.value; + const key = defKey(collection, item); + if (seen.has(key)) continue; + seen.add(key); + keys.push([collection, item]); + } + const defs = new Map(); + if (keys.length === 0) return defs; + const values = await chain.assetHub.query.Scarcity.ItemDefs.getValues(keys, at); + keys.forEach(([collection, item], index) => { + const value = values[index]; + if (value !== undefined) defs.set(defKey(collection, item), value); + }); + return defs; +} + +/** + * One layer's `[key, value]` pairs as a raw bag. + * + * `Object.create(null)` for the reason `byItem` in items.ts gives: keys are + * author-supplied bytes, and a `__proto__` key on a `{}` bag would set the + * prototype instead of landing as an own property. + */ +function bagOf(pairs: RawBytes[][]): Record { + const bag: Record = Object.create(null); + for (const pair of pairs) { + const [key, value] = pair; + if (key === undefined || value === undefined) { + throw new NftsDecodeError("a metadata_batch layer row is not a key-value pair"); + } + bag[decodeMetadataKey(key)] = value; + } + return bag; +} + +function toDisplay( + instance: bigint, + answer: RawMetadataLayers, + defs: Map, +): InstanceDisplay { + const resolved = answer.resolved; + if (resolved === undefined) return { tag: "NotFound", instance }; + if (resolved.type !== "Instance") { + throw new NftsDecodeError( + "metadata_batch resolved an instance query to a different kind of target", + ); + } + // The resolved payload names the instance it answers for, so an answer + // that slipped position — right count, wrong order — cannot attach another + // instance's name and transferability to the id asked at this slot. + if (resolved.value.instance !== instance) { + throw new NftsDecodeError( + "metadata_batch resolved an answer under a different instance id than asked", + ); + } + const collection = bagOf(answer.collection); + const item = bagOf(answer.item); + const own = bagOf(answer.instance); + const [decodedCollection, decodedItem, decodedOwn] = [ + decodeBag(collection), + decodeBag(item), + decodeBag(own), + ] as const; + // Most specific wins: the later layer overrides, so the order is + // collection, item, instance — the same precedence `imageRefFrom` reads + // backwards. + const attributes = mergeMetadata(decodedCollection, decodedItem, decodedOwn); + // Absent only when the definition is gone from under a live instance, so + // the three fields it backs go null together rather than one at a time. + const def = defs.get(defKey(resolved.value.collection, resolved.value.item)); + return { + tag: "Found", + instance, + collection: resolved.value.collection, + item: resolved.value.item, + transferability: def ? toTransferability(def) : null, + supply: def?.supply ?? null, + liveSupply: def?.live_supply ?? null, + // The item's own layers only. The collection's `name` names the + // collection, not this instance, so it is reported beside rather than + // inherited — see the note on `InstanceDisplay.name`. + name: decodedOwn.name ?? decodedItem.name ?? null, + collectionName: decodedCollection.name ?? null, + imageRef: imageRefFrom([collection, item, own]), + rarity: attributes.rarity ?? null, + attributes, + }; +} + +if (import.meta.vitest) { + const { describe, expect, test } = import.meta.vitest; + + const utf8 = (text: string) => new TextEncoder().encode(text); + const pair = (key: string, value: string | Uint8Array): [Uint8Array, Uint8Array] => [ + utf8(key), + typeof value === "string" ? utf8(value) : value, + ]; + const BLOCK = { hash: `0x${"44".repeat(32)}`, number: 321 }; + + /** A `Found` answer whose instance resolves to (collection 7, item 3). */ + const found = ( + layers: Partial>, + resolved: { instance: bigint; collection: number; item: number }, + ): RawMetadataLayers => ({ + resolved: { type: "Instance", value: resolved }, + collection: layers.collection ?? [], + item: layers.item ?? [], + instance: layers.instance ?? [], + }); + const missing = (): RawMetadataLayers => ({ collection: [], item: [], instance: [] }); + + const def = (transferability: string, supply = 10, liveSupply = 7): RawItemDef => + ({ + supply, + live_supply: liveSupply, + transferability: { type: transferability }, + }) as RawItemDef; + + /** Every definition the fakes use unless a test says otherwise. */ + const DEFS: Record = { + "7/3": def("Transferable", 500, 499), + "0/0": def("Transferable"), + "0/1": def("Transferable"), + "2/0": def("Soulbound", 1, 1), + }; + + function fakeChain( + answer: ( + queries: Array<{ type: string; value: bigint }>, + ) => + | { success: true; value: RawMetadataLayers[] } + | { success: false; value: { type: "TooLarge"; value: { max: number } } }, + defs: Record = DEFS, + ) { + const batches: bigint[][] = []; + const defKeys: Array> = []; + let blocks = 0; + const chain = { + assetHub: { + query: { + Scarcity: { + ItemDefs: { + getValues: async (keys: Array<[number, number]>) => { + defKeys.push(keys); + return keys.map(([c, i]) => defs[`${c}/${i}`]); + }, + }, + }, + }, + apis: { + ScarcityApi: { + metadata_batch: async (queries: Array<{ type: string; value: bigint }>) => { + batches.push(queries.map((q) => q.value)); + return answer(queries); + }, + }, + }, + }, + raw: { + assetHub: { + getFinalizedBlock: async () => { + blocks += 1; + return BLOCK; + }, + getBestBlocks: async () => { + blocks += 1; + return [BLOCK]; + }, + }, + }, + } as unknown as NftsInstancesChain; + return { chain, batches, defKeys, blocks: () => blocks }; + } + + const answerByInstance = + (state: Record) => + (queries: Array<{ type: string; value: bigint }>) => ({ + success: true as const, + value: queries.map((q) => state[q.value.toString()] ?? missing()), + }); + + describe("getInstanceDisplays", () => { + test("resolves the three layers most specific first", async () => { + const { chain, batches } = fakeChain( + answerByInstance({ + "9": found( + { + collection: [ + pair("name", "Animals"), + pair("rarity", "common"), + pair("palette", "warm"), + ], + item: [pair("name", "Red Panda"), pair("rarity", "rare")], + instance: [pair("name", "Red Panda #9")], + }, + { instance: 9n, collection: 7, item: 3 }, + ), + }), + ); + const result = await getInstanceDisplays(chain, [9n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.at).toEqual({ blockHash: BLOCK.hash, blockNumber: BLOCK.number }); + expect(result.value.displays).toEqual([ + { + tag: "Found", + instance: 9n, + collection: 7, + item: 3, + transferability: "Transferable", + supply: 500, + liveSupply: 499, + name: "Red Panda #9", + collectionName: "Animals", + rarity: "rare", + imageRef: null, + attributes: { + name: "Red Panda #9", + rarity: "rare", + palette: "warm", + }, + }, + ]); + expect(batches).toEqual([[9n]]); + }); + + test("a collection name is not reported as the instance's name", async () => { + // The live shape this exists for: the collection names itself and + // titles its items by `archetype`, so merging the two layers would + // report "Hearth" for an item the deployment calls "Greek Coffee". + // Found by porting the read into an app whose own test caught it. + const { chain } = fakeChain( + answerByInstance({ + "4": found( + { + collection: [pair("name", "Hearth"), pair("maxTiltDeg", "30")], + item: [pair("archetype", "greek-coffee")], + }, + { instance: 4n, collection: 0, item: 0 }, + ), + }), + ); + const result = await getInstanceDisplays(chain, [4n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + const display = result.value.displays[0]; + expect(display?.tag).toBe("Found"); + if (display?.tag !== "Found") return; + expect(display.name).toBeNull(); + expect(display.collectionName).toBe("Hearth"); + // The merged bag still carries both, and still resolves the keys + // where inheriting the collection's value is the right answer. + expect(display.attributes.name).toBe("Hearth"); + expect(display.attributes.maxTiltDeg).toBe("30"); + }); + + test("the instance's own name wins over its item's", async () => { + const { chain } = fakeChain( + answerByInstance({ + "4": found( + { + collection: [pair("name", "Animals")], + item: [pair("name", "Red Panda")], + instance: [pair("name", "Red Panda #4")], + }, + { instance: 4n, collection: 0, item: 0 }, + ), + }), + ); + const result = await getInstanceDisplays(chain, [4n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + const display = result.value.displays[0]; + expect(display?.tag === "Found" && display.name).toBe("Red Panda #4"); + expect(display?.tag === "Found" && display.collectionName).toBe("Animals"); + }); + + test("an unresolved query is NotFound, positionally", async () => { + const { chain } = fakeChain( + answerByInstance({ + "1": found({}, { instance: 1n, collection: 0, item: 0 }), + }), + ); + const result = await getInstanceDisplays(chain, [404n, 1n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.displays.map((d) => d.tag)).toEqual(["NotFound", "Found"]); + expect(result.value.displays[0]).toEqual({ tag: "NotFound", instance: 404n }); + }); + + test("a resolved instance with empty layers is Found, not NotFound", async () => { + // A claim-minted instance on the live chain: it exists and carries + // no metadata at all. The empty bag must not read as a miss. + const { chain } = fakeChain( + answerByInstance({ + "5": found({}, { instance: 5n, collection: 2, item: 0 }), + }), + ); + const result = await getInstanceDisplays(chain, [5n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.displays[0]).toEqual({ + tag: "Found", + instance: 5n, + collection: 2, + item: 0, + transferability: "Soulbound", + supply: 1, + liveSupply: 1, + name: null, + collectionName: null, + rarity: null, + imageRef: null, + attributes: {}, + }); + }); + + test("the item definition fills transferability and the supplies", async () => { + const { chain, defKeys } = fakeChain( + answerByInstance({ + "5": found({}, { instance: 5n, collection: 2, item: 0 }), + }), + ); + const result = await getInstanceDisplays(chain, [5n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + const display = result.value.displays[0]; + expect(display?.tag === "Found" && display.transferability).toBe("Soulbound"); + expect(defKeys).toEqual([[[2, 0]]]); + }); + + test("one definition read, keyed by what resolved and deduplicated", async () => { + // Three instances of two definitions: two keys, not three. + const { chain, defKeys } = fakeChain( + answerByInstance({ + "1": found({}, { instance: 1n, collection: 0, item: 0 }), + "2": found({}, { instance: 2n, collection: 0, item: 0 }), + "3": found({}, { instance: 3n, collection: 0, item: 1 }), + }), + ); + const result = await getInstanceDisplays(chain, [1n, 2n, 3n]); + expect(result.ok).toBe(true); + expect(defKeys).toEqual([ + [ + [0, 0], + [0, 1], + ], + ]); + }); + + test("a definition gone from under a live instance is null, not an error", async () => { + const { chain } = fakeChain( + answerByInstance({ + "8": found( + { instance: [pair("name", "Orphan")] }, + { + instance: 8n, + collection: 9, + item: 9, + }, + ), + }), + {}, // no definitions at all + ); + const result = await getInstanceDisplays(chain, [8n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + const display = result.value.displays[0]; + expect(display?.tag).toBe("Found"); + if (display?.tag !== "Found") return; + // The metadata still reads; only the definition-backed fields go null. + expect(display.name).toBe("Orphan"); + expect(display.transferability).toBeNull(); + expect(display.supply).toBeNull(); + expect(display.liveSupply).toBeNull(); + }); + + test("an unknown transferability variant is an error, not a guess", async () => { + const { chain } = fakeChain( + answerByInstance({ + "1": found({}, { instance: 1n, collection: 0, item: 0 }), + }), + { "0/0": def("Escrowed") }, + ); + const result = await getInstanceDisplays(chain, [1n]); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(NftsDecodeError); + }); + + test("all-NotFound costs no definition read", async () => { + const { chain, defKeys } = fakeChain(answerByInstance({})); + const result = await getInstanceDisplays(chain, [404n, 405n]); + expect(result.ok).toBe(true); + expect(defKeys).toEqual([]); + }); + + test("the image is read from the most specific layer that sets it", async () => { + const digest = new Uint8Array(32).fill(0xab); + const { chain } = fakeChain( + answerByInstance({ + "2": found( + { + collection: [pair("image", "bafkcollection")], + item: [pair("image", digest)], + }, + { instance: 2n, collection: 0, item: 1 }, + ), + }), + ); + const result = await getInstanceDisplays(chain, [2n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + const display = result.value.displays[0]; + expect(display?.tag === "Found" && display.imageRef).toEqual({ + hex: `0x${"ab".repeat(32)}`, + text: null, + }); + }); + + test("a bag with a __proto__ key stays a bag", async () => { + const { chain } = fakeChain( + answerByInstance({ + "3": found( + { instance: [pair("__proto__", "polluted")] }, + { instance: 3n, collection: 0, item: 0 }, + ), + }), + ); + const result = await getInstanceDisplays(chain, [3n]); + expect(result.ok).toBe(true); + if (!result.ok) return; + const display = result.value.displays[0]; + expect(display?.tag === "Found" && display.attributes.__proto__).toBe("polluted"); + }); + + test("a full list is one call, answered in order", async () => { + const state: Record = {}; + const ids = Array.from({ length: MAX_INSTANCES_PER_READ }, (_, i) => BigInt(i)); + for (const id of ids) { + state[id.toString()] = found( + { instance: [pair("name", `#${id}`)] }, + { instance: id, collection: 0, item: 0 }, + ); + } + const { chain, batches } = fakeChain(answerByInstance(state)); + const result = await getInstanceDisplays(chain, ids); + expect(result.ok).toBe(true); + if (!result.ok) return; + // The cap is the chunk size, so a maxed-out read never splits. + expect(batches.map((b) => b.length)).toEqual([MAX_INSTANCES_PER_READ]); + expect(result.value.displays.map((d) => (d.tag === "Found" ? d.name : null))).toEqual( + ids.map((id) => `#${id}`), + ); + }); + + test("one instance over the cap is refused before the pin", async () => { + const { chain, batches, blocks } = fakeChain(answerByInstance({})); + const ids = Array.from({ length: MAX_INSTANCES_PER_READ + 1 }, (_, i) => BigInt(i)); + const result = await getInstanceDisplays(chain, ids); + expect(result.ok).toBe(false); + if (result.ok) return; + // Not an NftsIdError: every id here can address chain state, there + // are just too many of them. + expect(result.error).toBeInstanceOf(ProductNftsError); + expect(result.error).not.toBeInstanceOf(NftsIdError); + expect(result.error.message).toContain(String(MAX_INSTANCES_PER_READ)); + expect(blocks()).toBe(0); + expect(batches).toEqual([]); + }); + + test("a TooLarge naming a smaller cap re-chunks to it", async () => { + const state: Record = {}; + const ids = Array.from({ length: 10 }, (_, i) => BigInt(i)); + for (const id of ids) { + state[id.toString()] = found({}, { instance: id, collection: 0, item: 0 }); + } + const answer = answerByInstance(state); + const { chain, batches } = fakeChain((queries) => + queries.length > 4 + ? { success: false, value: { type: "TooLarge", value: { max: 4 } } } + : answer(queries), + ); + const result = await getInstanceDisplays(chain, ids); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.displays).toHaveLength(10); + // One refused attempt at 10, then the re-run at the reported cap. + expect(batches.map((b) => b.length)).toEqual([10, 4, 4, 2]); + }); + + test("a TooLarge at the runtime's own cap is an error naming the numbers", async () => { + const { chain } = fakeChain(() => ({ + success: false, + value: { type: "TooLarge", value: { max: 1024 } }, + })); + const result = await getInstanceDisplays(chain, [1n]); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toContain("too large"); + // The variant and the cap the runtime reported, so a caller + // debugging a refusal is not pointed at batch sizing it already + // satisfies. + expect(result.error.message).toContain("TooLarge"); + expect(result.error.message).toContain("1024"); + }); + + test("a second TooLarge refusal is an error, not another re-run", async () => { + // The retry already runs at the cap the runtime itself reported, so + // a runtime whose cap keeps shrinking is disagreeing with itself — + // re-chunking again would silently re-ask everything per shrink. + let cap = 4; + const { chain, batches } = fakeChain(() => { + const refusal = { + success: false as const, + value: { type: "TooLarge" as const, value: { max: cap } }, + }; + cap = 2; + return refusal; + }); + const ids = Array.from({ length: 10 }, (_, i) => BigInt(i)); + const result = await getInstanceDisplays(chain, ids); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.message).toContain("too large"); + // One attempt at 10, one re-run at the reported cap of 4 — and no + // third wave at 2. + expect(batches.map((b) => b.length)).toEqual([10, 4, 4, 2]); + }); + + test("an answer with the wrong number of entries is an error", async () => { + const { chain } = fakeChain(() => ({ success: true, value: [missing()] })); + const result = await getInstanceDisplays(chain, [1n, 2n]); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(NftsDecodeError); + }); + + test("an answer carrying a different instance id than asked is an error", async () => { + // Right count, wrong identity — a shifted or reordered answer must + // not attach another instance's name and transferability to the + // asked id, where a Soulbound one could render as Transferable. + const { chain } = fakeChain(() => ({ + success: true, + value: [found({}, { instance: 2n, collection: 0, item: 0 })], + })); + const result = await getInstanceDisplays(chain, [1n]); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(NftsDecodeError); + }); + + test("a query resolved to a non-instance target is an error", async () => { + const { chain } = fakeChain(() => ({ + success: true, + value: [ + { + resolved: { type: "Collection", value: 7 }, + collection: [], + item: [], + instance: [], + } as RawMetadataLayers, + ], + })); + const result = await getInstanceDisplays(chain, [1n]); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(NftsDecodeError); + }); + + test("no instances is no round trip past the pin", async () => { + const { chain, batches, blocks } = fakeChain(answerByInstance({})); + const result = await getInstanceDisplays(chain, []); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.displays).toEqual([]); + expect(batches).toEqual([]); + expect(blocks()).toBe(1); + }); + + test("an instance id outside u64 is refused before the pin", async () => { + const { chain, blocks } = fakeChain(answerByInstance({})); + for (const id of [-1n, 1n << 64n]) { + const result = await getInstanceDisplays(chain, [id]); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(NftsIdError); + } + expect(blocks()).toBe(0); + }); + + test("joins a block another read pinned", async () => { + const { chain, blocks } = fakeChain(answerByInstance({})); + const given = { blockHash: `0x${"aa".repeat(32)}`, blockNumber: 5 }; + const result = await getInstanceDisplays(chain, [], { at: given }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.at).toEqual(given); + expect(blocks()).toBe(0); + }); + + test("an aborted signal is an error before any round trip", async () => { + const { chain, blocks, batches } = fakeChain(answerByInstance({})); + const controller = new AbortController(); + controller.abort(); + const result = await getInstanceDisplays(chain, [1n], { + signal: controller.signal, + }); + expect(result.ok).toBe(false); + expect(blocks()).toBe(0); + expect(batches).toEqual([]); + }); + }); + + describe("getInstanceDisplay", () => { + test("answers for the one instance", async () => { + const { chain } = fakeChain( + answerByInstance({ + "9": found( + { item: [pair("name", "Red Panda")] }, + { instance: 9n, collection: 7, item: 3 }, + ), + }), + ); + const result = await getInstanceDisplay(chain, 9n); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.display.tag).toBe("Found"); + expect(result.value.display.tag === "Found" && result.value.display.name).toBe( + "Red Panda", + ); + }); + + test("a miss is NotFound on the ok channel", async () => { + const { chain } = fakeChain(answerByInstance({})); + const result = await getInstanceDisplay(chain, 404n); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.display).toEqual({ tag: "NotFound", instance: 404n }); + }); + }); +} diff --git a/product-sdk/packages/nfts/src/items.ts b/product-sdk/packages/nfts/src/items.ts index aaeb5c8f..d04b25a2 100644 --- a/product-sdk/packages/nfts/src/items.ts +++ b/product-sdk/packages/nfts/src/items.ts @@ -16,6 +16,7 @@ import { fillByIdWindow, isValidId, pageBounds } from "./paging.js"; import { matchChainEntryError, NftsChainEntryError, + NftsDecodeError, NftsIdError, ProductNftsError, } from "./errors.js"; @@ -35,8 +36,28 @@ import type { RawItemDef, RawMetadataEntry, ReadAt, + Transferability, } from "./types.js"; +/** + * The `transferability` variant of an item definition, refusing one this + * package does not know. + * + * A variant added to the runtime later would otherwise reach a caller as a + * string the `Transferability` union says cannot occur, and the field that + * decides whether a UI may offer a send is the wrong one to guess at. The one + * decoder for the one storage field: `getCollectionItems` and + * `getInstanceDisplays` read the same `ItemDefs` entry and must refuse the + * same way. + */ +export function toTransferability(def: RawItemDef): Transferability { + const type = def.transferability?.type; + if (type !== "Transferable" && type !== "Soulbound") { + throw new NftsDecodeError("an item definition carries an unknown transferability"); + } + return type; +} + /** * Group `ItemMetadata` rows by item index, keeping the raw bytes. * @@ -245,7 +266,7 @@ export async function getCollectionItems( index, supply: def.supply, liveSupply: def.live_supply, - transferability: def.transferability.type, + transferability: toTransferability(def), name: decoded.name ?? decodedDefaults.name ?? null, imageRef: imageRefFrom([defaultBag, raw]), rarity: decoded.rarity ?? decodedDefaults.rarity ?? null, @@ -1037,6 +1058,33 @@ if (import.meta.vitest) { "Transferable", ]); }); + + test("an unknown transferability variant is an error, not a guess", async () => { + // The same refusal `getInstanceDisplays` makes, through the same + // decoder: a variant added to the runtime later must not reach a + // caller as a string the union says cannot occur. + const { chain } = fakeChain({ + record: { owner: "alice", item_count: 1, next_item_index: 1 }, + defs: [ + [ + 0, + { + supply: 1, + live_supply: 1, + // The runtime can widen the variant set under a + // package built against today's descriptors. + transferability: { + type: "Escrowed", + } as unknown as RawItemDef["transferability"], + }, + ], + ], + }); + const result = await getCollectionItems(chain, 0); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toBeInstanceOf(NftsDecodeError); + }); }); describe("getCollectionItems, an empty page", () => { diff --git a/product-sdk/packages/nfts/src/types.ts b/product-sdk/packages/nfts/src/types.ts index 47037d9c..8ac2b525 100644 --- a/product-sdk/packages/nfts/src/types.ts +++ b/product-sdk/packages/nfts/src/types.ts @@ -281,7 +281,18 @@ export interface CollectionItem { liveSupply: number; /** Whether a minted instance can be sent on. A `Soulbound` one stays with its first owner. */ transferability: Transferability; - /** The `name` metadata of the item, or `null` when neither it nor its collection sets one. */ + /** + * The `name` metadata of the item, or `null` when neither it nor its + * collection sets one. + * + * **Inherits the collection's `name` when the item sets none**, which + * conflates the item's name with the name of the collection itself — a + * catalogue whose items are titled by another key reports every item under + * the collection's name. {@link InstanceDisplay.name} separates the two + * (see the note there) and is the shape this field should grow into; until + * then, a join across the two reads will see `item.name` set where + * `display.name` is `null` for the same item, by design. + */ name: string | null; /** * The `image` metadata of the item, or `null` when neither it nor its collection @@ -376,6 +387,115 @@ export type CollectionItemsResult = } | { tag: "NotFound"; at: FinalizedSnapshot; id: number }; +/** + * The display metadata of one minted NFT, or a clean miss. + * + * An instance nobody minted, or one already burned, is not a failure: the + * runtime was asked and answered that there is nothing under that id, and the + * answer travels inside the `ok` payload. The signal is `metadata_batch` + * declining to resolve the query, not an empty bag — a freshly claim-minted + * instance carries no metadata at all on the live chain, and it is still + * `Found`. That distinction is the reason this is a tagged shape rather than a + * bag that is empty two different ways. + */ +export type InstanceDisplay = + | { + tag: "Found"; + /** The instance id asked about. */ + instance: bigint; + /** The collection the instance was minted from, as the runtime resolved it. */ + collection: number; + /** The item definition the instance was minted from, within its collection. */ + item: number; + /** + * Whether this instance can be sent on, from its item definition. + * + * A `Soulbound` one stays with its first owner, so a UI offering to + * send it is offering a transaction the runtime will reject. Read + * always rather than behind an option, unlike + * {@link CollectionItem.attributes}: the key is the + * `(collection, item)` the instance already resolved to, so it costs + * one serial hop and no bytes beyond the instances asked about. + * + * `null` means the item definition is **gone** from the chain while + * the instance survives — a real state, not "not fetched". The three + * definition-backed fields are `null` together. + */ + transferability: Transferability | null; + /** Instances the definition may ever mint, or `null` when it is gone. */ + supply: number | null; + /** Instances currently alive, which is `supply` less those burned, or `null`. */ + liveSupply: number | null; + /** + * The instance's own `name` metadata — its instance layer, else its + * item layer — or `null` when neither sets one. + * + * **Deliberately does not inherit the collection's `name`**, which + * is reported separately as {@link InstanceDisplay.collectionName}. + * Inheritance is right for `image` and `rarity`, where the + * collection sets a default for its items, and wrong for `name`, + * where the collection layer holds the name of a *different thing*: + * the collection itself. A deployment can and does set a collection + * `name` while titling items by `archetype`, and a merged `name` + * reports "Hearth" for an item called "Greek Coffee". + * + * So the common title expression is explicit about which it wants: + * + * ```ts + * const title = display.name ?? display.collectionName ?? "Untitled"; + * ``` + * + * Note this differs from {@link CollectionItem.name}, which merges + * the two. That read shipped first and conflates them; this one is + * the shape both should have. + */ + name: string | null; + /** + * The `name` metadata of the collection this instance belongs to, or + * `null` when the collection sets none. + * + * The collection's own name, never the item's. See + * {@link InstanceDisplay.name} for why the two are separate. + */ + collectionName: string | null; + /** The `image` metadata, read both ways, or `null`. See {@link CollectionItem.imageRef}. */ + imageRef: ImageRef | null; + /** The `rarity` metadata, most specific layer winning, or `null` when unset. */ + rarity: string | null; + /** + * Every metadata key across all three layers, the most specific layer + * winning per key: instance over item over collection, mirroring the + * pallet's own `instance_metadata_of`. + * + * Always present, unlike {@link CollectionItem.attributes}, and empty + * when the instance genuinely carries no metadata. The asymmetry is + * honest: a catalogue page pays a prefix scan for the open bag, so it + * is opt-in there, while `metadata_batch` returns whole layers whether + * or not anyone wants them, so withholding them here would save + * nothing. + * + * The schema is open and the values are decoded text-or-hex; the notes + * on {@link CollectionItem.attributes} apply unchanged. Deployment + * conventions beyond `name`, `image` and `rarity` — an identity + * `hash`, a `manifest` CID — live in this bag for the caller to lift. + */ + attributes: Record; + } + | { tag: "NotFound"; instance: bigint }; + +/** What one `getInstanceDisplays` call returns. */ +export interface InstanceDisplaysResult { + at: FinalizedSnapshot; + /** One per instance asked for, in the order asked. */ + displays: InstanceDisplay[]; +} + +/** What one `getInstanceDisplay` call returns. */ +export interface InstanceDisplayResult { + at: FinalizedSnapshot; + display: InstanceDisplay; +} + /** `Scarcity.Collections`, narrowed to the fields these reads use. */ export interface RawCollection { owner: string; @@ -421,3 +541,48 @@ export type RawBytes = Uint8Array | { asBytes(): Uint8Array }; export interface RawMetadataEntry { value: RawBytes; } + +/** + * One `ScarcityApi.metadata_batch` query: a minted instance, an item + * definition, or a bare collection. + * + * `getInstanceDisplays` only ever sends `Instance` queries; the other two + * variants are typed because the runtime takes them, so a future read of + * pre-mint metadata composes on the same entry rather than growing a second + * contract. + */ +export type RawMetadataQuery = + | { type: "Instance"; value: bigint } + | { type: "Item"; value: { collection: number; item: number } } + | { type: "Collection"; value: number }; + +/** + * What `metadata_batch` resolved one query to, when its target exists. + * + * For an `Instance` query this is where the instance → (collection, item) + * mapping comes back: the runtime walks it to assemble the layers anyway, so + * the caller is told rather than left to re-derive it from owner-keyed storage. + */ +export type RawMetadataTarget = + | { type: "Instance"; value: { instance: bigint; collection: number; item: number } } + | { type: "Item"; value: { collection: number; item: number } } + | { type: "Collection"; value: number }; + +/** + * One `metadata_batch` answer, positionally matched to its query. + * + * `resolved` is absent exactly when the target does not exist at the block + * asked: the runtime cannot name the collection and item of an instance nobody + * minted. The layers of a missing target are empty, but empty layers alone do + * not mean missing — a claim-minted instance has a `resolved` and no metadata. + * + * Each layer is `[key, value]` byte pairs. For an `Instance` query all three + * layers arrive; for an `Item` query the `instance` layer is empty, and for a + * `Collection` query the `item` layer is too. + */ +export interface RawMetadataLayers { + resolved?: RawMetadataTarget | undefined; + collection: RawBytes[][]; + item: RawBytes[][]; + instance: RawBytes[][]; +} diff --git a/product-sdk/packages/sdk/src/nfts/contract.test.ts b/product-sdk/packages/sdk/src/nfts/contract.test.ts index 2272ab83..5d413a5e 100644 --- a/product-sdk/packages/sdk/src/nfts/contract.test.ts +++ b/product-sdk/packages/sdk/src/nfts/contract.test.ts @@ -25,7 +25,7 @@ * hypothetical: it is the live state of the other supported network. */ import type { getChainAPI } from "@parity/product-sdk-chain-client"; -import type { NftsChain, NftsCreditsChain } from "@parity/product-sdk-nfts"; +import type { NftsChain, NftsCreditsChain, NftsInstancesChain } from "@parity/product-sdk-nfts"; import { expect, test } from "vitest"; // The false branch must be `false`, not `never`. `never` is assignable to @@ -43,6 +43,12 @@ type PaseoSatisfiesContract = Assert; +// The instance-display read takes its own contract — one runtime API, +// `ScarcityApi.metadata_batch`. This is the line that checks the hand-written +// query enum and the `resolved` + three-layer result against what PAPI +// generated from the pinned runtime. +type PaseoSatisfiesInstances = Assert; + // The negative control, and a real fact about the network rather than a // hypothetical: devnet Asset Hub has no `Scarcity` and no `NftClaims`. If // `NftsChain` ever stopped constraining, this `@ts-expect-error` would report @@ -50,6 +56,11 @@ type PaseoSatisfiesCredits = Assert; +// No `Scarcity` pallet also means no `ScarcityApi`, so the instances contract +// must refuse the devnet client the same way. +// @ts-expect-error devnet Asset Hub has no ScarcityApi runtime API +type DevnetLacksScarcityApi = Assert; + test("nfts chain contract holds against the paseo descriptor", () => { // The assertions above are compile-time. This keeps vitest from reporting // the file as having no tests, and documents that a green run means tsc diff --git a/product-sdk/pending-changesets/nfts-instance-displays.md b/product-sdk/pending-changesets/nfts-instance-displays.md new file mode 100644 index 00000000..c05d7703 --- /dev/null +++ b/product-sdk/pending-changesets/nfts-instance-displays.md @@ -0,0 +1,50 @@ +--- +"@parity/product-sdk-nfts": minor +"@parity/product-sdk": minor +--- + +`getInstanceDisplays(chain, instances)` reads the display metadata of minted +NFTs by instance id: `name`, `collectionName`, `rarity`, `imageRef`, the full +three-layer `attributes` bag, the (collection, item) each instance was minted +from as the runtime resolves it, and `transferability`, `supply` and +`liveSupply` from its item definition. + +`name` is the instance's own — its instance layer, else its item layer — and +does **not** inherit the collection's, which is reported beside it as +`collectionName`. Inheriting is right for `image` and `rarity`, where the +collection sets a default for its items, and wrong for `name`, where the +collection layer holds the name of a different thing. A deployment that names +its collection and titles items by `archetype` would otherwise see every item +wearing the collection's name. This differs from `CollectionItem.name`, which +merges the two; that read shipped first and this is the shape both should +have. Positional like `previewClaim`, with a per-instance `Found` / +`NotFound` tag — an unminted or burned instance is a clean miss on the `ok` +channel, and an instance with no metadata at all (every claim-minted one +today) is still `Found`. `getInstanceDisplay` is the same read for one id. + +The read takes its own chain contract, `NftsInstancesChain`: the runtime API +`api.ScarcityApi.metadata_batch` and `query.Scarcity.ItemDefs`, both of which +a pruned descriptor whitelist must carry, plus the raw client. A `getChainAPI` +client satisfies it whole. + +The read is capped at `MAX_INSTANCES_PER_READ` (128) instances per call and +refuses above it rather than paging: 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. The cap is the package's +contract rather than a reading of the chain, though it coincides with the live +runtime's `metadata_batch` cap, so a full read is one runtime call there and a +deployment configured lower reports its own cap for the read to re-chunk to. +The definitions are then one keyed read over the deduplicated +`(collection, item)` pairs, so they cost one serial hop and nothing per +instance. The three definition-backed fields are `null` together, and only +when the definition is gone from under a live instance. + +`NftsIdError` now also reports out-of-range `u64` instance ids; its `id` field +widened from `number` to `number | bigint`. The constructor shape is unchanged +(`(id, options?)`): which space the id missed follows from the value's type. + +`getCollectionItems` now refuses an item definition carrying a transferability +variant this package does not know with an `NftsDecodeError`, instead of +passing the unknown string through a field typed as the `Transferability` +union. It is the same refusal `getInstanceDisplays` makes, through the one +shared decoder. diff --git a/product-sdk/skills/product-sdk-nfts/SKILL.md b/product-sdk/skills/product-sdk-nfts/SKILL.md index 9deca3cc..a5d6d66a 100644 --- a/product-sdk/skills/product-sdk-nfts/SKILL.md +++ b/product-sdk/skills/product-sdk-nfts/SKILL.md @@ -9,13 +9,16 @@ description: > schema and the two readings of ImageRef, why a missing collection is a success value rather than an error, getClaims across the People chain and Asset Hub with the four states a credit can be in, previewClaim and why it is the item a claim will produce, getVerifiedArtwork and why bytes - are withheld unless they hash to the reference, and the purse-scoped reads that do not exist yet. + are withheld unless they hash to the reference, getInstanceDisplays for the display metadata of + minted NFTs by instance id with Found/NotFound per instance, and the purse-scoped reads that do + not exist yet. --- # Product SDK NFTs -`@parity/product-sdk-nfts` reads the Scarcity catalogue on Asset Hub. Three functions, all pure -reads, none needing an identity, a purse or a second chain. Also available as +`@parity/product-sdk-nfts` reads the Scarcity catalogue on Asset Hub. Three catalogue functions, +all pure reads, none needing an identity, a purse or a second chain — plus `getInstanceDisplays` +for minted NFTs by instance id (see "Minted Instances" below). Also available as `@parity/product-sdk/nfts`, the same code re-exported from `@parity/product-sdk`. - `getClaimableCollections(chain, options?)` reads the collections registered to accept claims. Powers a @@ -240,6 +243,10 @@ six, **including the ones its own code never reads**. An app resolving display m `ScarcityApi.metadata_batch` typically carries none of the three metadata/`ItemDefs` entries, so this bites on the first call. +`getInstanceDisplays` touches two, `api.ScarcityApi.metadata_batch` and `query.Scarcity.ItemDefs` +(the second shared with the catalogue reads); `previewClaim` adds `api.NftClaimsApi.preview_mints`, +and `getClaims` its own set (see the package doc). Whitelist per read you call. + The symptom is the PAPI `Incompatible runtime entry Storage(Scarcity.CollectionMetadata)`, which reads like descriptor drift and is not. These reads report it as `NftsChainEntryError`, which names the entry in its message, carries it on `error.entry` for programmatic handling, and keeps PAPI's @@ -258,8 +265,9 @@ Two follow-ons that cost real time: `Scarcity` stores metadata as untyped `Vec` keys to `Vec` values in three layers, each overriding the last for the same key. A catalogue read merges the first two, collection defaults underneath and the -item overrides on top. `InstanceMetadata` is deliberately not consulted: it keys on an instance -id, so it describes a minted NFT rather than a catalogue entry. +item overrides on top. `InstanceMetadata` is deliberately not consulted by the catalogue reads: it +keys on an instance id, so it describes a minted NFT rather than a catalogue entry — that layer +belongs to `getInstanceDisplays`, which merges all three. Nothing in the runtime declares the keys or their types. @@ -355,12 +363,76 @@ hash to the digest the reference names. Where the bytes come from is the caller' `Verified` with the bytes, `Missing`, `Mismatch` with the bytes withheld, and `Unreadable` when the reference decodes to no address or names a multihash other than blake2b-256 or sha2-256. +## Minted Instances + +`getInstanceDisplays(chain, instances, options?)` reads the display metadata of minted NFTs by +instance id — what somebody *holds*, where the catalogue reads describe what a collection +*defines*. It is positional like `previewClaim`: `displays[i]` answers `instances[i]`. The chain +contract is its own, `NftsInstancesChain` — `ScarcityApi.metadata_batch` plus `Scarcity.ItemDefs`, +and the raw client; a `getChainAPI` client satisfies it whole. + +```typescript +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 + // instance, collection, item, transferability, supply, liveSupply, + // name | null, collectionName | null, rarity | null, imageRef | null, attributes + const title = display.name ?? display.collectionName ?? `Instance ${display.instance}`; + console.log(title, display.collection, display.item); + if (display.transferability === "Transferable") offerSend(display); + } +} +``` + +Things that differ from the catalogue reads, each for a reason: + +- **Per-instance `Found` / `NotFound`.** An instance nobody minted, or one already burned, is a + `NotFound` display on the `ok` channel. The signal is the runtime declining to resolve the query, + **not** an empty 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 `attributes`. +- **`collection` and `item` come back resolved.** The runtime reports which (collection, item) each + instance was minted from, so a shelf can group by collection, or join `getCollectionItems`, + without a second lookup. +- **`attributes` is always filled, all three layers merged** (instance over item over collection, + the pallet's own precedence). No `attributes: true` flag: `metadata_batch` returns whole layers + whether or not anyone wants them, so withholding the bag would save nothing. Deployment + conventions beyond `name` / `image` / `rarity` — an identity `hash`, a `manifest` CID — are in + the bag for you to lift. +- **`name` is the instance's own, and `collectionName` is separate.** Unlike `CollectionItem.name`, + this read does not inherit the collection's `name` into the item's. Inheriting is right for + `image` and `rarity`, where the collection sets a default for its items, and wrong for `name`, + where the collection layer holds the name of a *different thing*. A deployment that names its + collection and titles items by `archetype` would otherwise see every item called "Hearth". The + merged `attributes` bag still carries both, so nothing is hidden. +- **`transferability`, `supply` and `liveSupply` come from the item definition**, which is not + metadata, so a second keyed `ItemDefs` read fills them. Also not optional, and for a sharper + reason than the bag: whether an instance is `Soulbound` decides whether your UI may offer to + send it, and the keys are the `(collection, item)` the instance already resolved to — deduped, + so one hop and no bytes beyond what you asked about. The three are `null` **together**, and + only when the item definition is gone from under a live instance. That is a real chain state, + not "not fetched" — the opposite of how `null` reads on `CollectionItem.attributes`. +- **Capped, not paged.** At most `MAX_INSTANCES_PER_READ` (128) instances per call, refused above + that. Every other read here walks a space the chain sizes, so it hands back a cursor; this one + answers a list you already hold, so a cursor would page you over your own input. Split the list + instead. The cap is this package's contract, not a reading of the chain — it coincides with the + live runtime's `metadata_batch` cap, which is why a full read is one runtime call there, but a + deployment configured lower just reports its cap and the read re-chunks to it. + +`getInstanceDisplay(chain, instance, options?)` is the same read for one id, for a detail view. +Looping it over a shelf forfeits the batching; pass the list. + +Instance ids are `u64` **bigints**, as PAPI returns them. Where ids come from is the caller's +business — a transfer record, an indexer, a purse walk the app does itself — which is exactly why +this read is not blocked on the purse primitive below. + ## Not Built Yet - **Nothing purse-scoped.** `getOwnedNfts`, `getNextEmptyPurse` and `findPurseHolding` need a purse primitive shared across apps, which the wallet does not expose. App-scoped product-account derivation is not a substitute: it is keyed by `productId`, so nothing derived under it can be - shared between two apps. + shared between two apps. `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. ## Common Mistakes @@ -380,3 +452,10 @@ reference decodes to no address or names a multihash other than blake2b-256 or s are `null` when it does not, which signals an inconsistency rather than an empty collection. 9. **Parsing `attributes` values as numbers** without handling text that is not numeric. 10. **Expecting `rarity` or `name` to be set.** Most collections on a live deployment set neither. +11. **Reading an empty `attributes` bag from `getInstanceDisplays` as "no such instance".** A + claim-minted instance carries no metadata and is still `Found`; the miss signal is the + `NotFound` tag. +12. **Looping `getInstanceDisplay` over a shelf.** One call with the list is a single runtime + operation for up to 128 instances; the loop is one per instance. +13. **Expecting `getInstanceDisplays` to page a list over 128.** It refuses rather than + truncating or handing back a cursor. Slice the list yourself — it is yours. From ecbc8f220f438a2f0c043210d42c23fb027226fe Mon Sep 17 00:00:00 2001 From: tarikgul Date: Mon, 5 Oct 2026 18:03:56 -0400 Subject: [PATCH 2/2] fix(nfts): re-chunk only the refused batch on TooLarge; flag Transferability behavior change On a metadata_batch TooLarge the read now re-chunks only the instances that refusal covered rather than re-running the whole list, keeping the chunks that already answered. Observable behavior is unchanged for the single-chunk public path; the saving shows if the input cap ever exceeds the runtime cap. Also calls out the getCollectionItems Transferability refusal in the changeset as a behavior change for consumers. --- product-sdk/packages/nfts/src/instances.ts | 20 +++++++++++-------- .../nfts-instance-displays.md | 6 +++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/product-sdk/packages/nfts/src/instances.ts b/product-sdk/packages/nfts/src/instances.ts index 74c5a130..06c8f9db 100644 --- a/product-sdk/packages/nfts/src/instances.ts +++ b/product-sdk/packages/nfts/src/instances.ts @@ -160,10 +160,10 @@ export async function getInstanceDisplay( * * 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-runs the whole batch at the runtime's number, once — 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. + * 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, @@ -186,11 +186,15 @@ async function readBatched( ); 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) { - return readBatched(chain, instances, at, max, true); + // 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 @@ -198,10 +202,10 @@ async function readBatched( 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 ${chunks[index]?.length ?? 0} as too large (${refusal.type}, runtime max ${reported})${after}`, + `metadata_batch refused a chunk of ${chunk.length} as too large (${refusal.type}, runtime max ${reported})${after}`, ); } - if (result.value.length !== chunks[index]?.length) { + if (result.value.length !== chunk.length) { throw new NftsDecodeError("metadata_batch answered a different number of queries"); } answers.push(...result.value); @@ -744,7 +748,7 @@ if (import.meta.vitest) { test("a second TooLarge refusal is an error, not another re-run", async () => { // The retry already runs at the cap the runtime itself reported, so // a runtime whose cap keeps shrinking is disagreeing with itself — - // re-chunking again would silently re-ask everything per shrink. + // re-chunking again would silently re-ask a chunk per shrink. let cap = 4; const { chain, batches } = fakeChain(() => { const refusal = { diff --git a/product-sdk/pending-changesets/nfts-instance-displays.md b/product-sdk/pending-changesets/nfts-instance-displays.md index c05d7703..8fd09805 100644 --- a/product-sdk/pending-changesets/nfts-instance-displays.md +++ b/product-sdk/pending-changesets/nfts-instance-displays.md @@ -47,4 +47,8 @@ widened from `number` to `number | bigint`. The constructor shape is unchanged variant this package does not know with an `NftsDecodeError`, instead of passing the unknown string through a field typed as the `Transferability` union. It is the same refusal `getInstanceDisplays` makes, through the one -shared decoder. +shared decoder. This is a behavior change for consumers: the two variants the +runtime has today still read as before, but a future third variant now fails +the read on the `err` channel where it previously reached callers as an +untyped string, so a consumer wanting to tolerate an unknown variant must +handle the `NftsDecodeError`.