Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions product-sdk/examples/nfts-demo/e2e/catalogue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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,
}) => {
Expand Down
9 changes: 9 additions & 0 deletions product-sdk/examples/nfts-demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,15 @@ <h2>Catalogue (getCollectionItems)</h2>
<h2>A collection nobody created</h2>
<div class="meta">u32 max reads as: <span id="missing-tag" data-testid="missing-tag">-</span></div>

<h2>Minted instances (getInstanceDisplays)</h2>
<div class="meta">Asked for: <span id="instances-asked" data-testid="instances-asked">-</span></div>
<div class="result">Found: <span id="instances-found" data-testid="instances-found">-</span></div>
<div class="meta">Resolved to collection/item: <span id="instances-targets" data-testid="instances-targets">-</span></div>
<div class="meta">First found: <span id="instance-name" data-testid="instance-name">-</span></div>
<div class="meta">Transferability: <span id="instance-transferability" data-testid="instance-transferability">-</span></div>
<div class="meta">Metadata keys: <span id="instance-attributes" data-testid="instance-attributes">-</span></div>
<div class="meta">u64 max reads as: <span id="instance-missing-tag" data-testid="instance-missing-tag">-</span></div>

<h2>Credits (getCredits)</h2>
<div class="meta">People block: <span id="credits-block" data-testid="credits-block">-</span></div>
<div class="result">Count: <span id="credits-count" data-testid="credits-count">-</span></div>
Expand Down
91 changes: 88 additions & 3 deletions product-sdk/examples/nfts-demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -39,6 +43,7 @@ import {
getClaimableCollections,
getCollectionItems,
getClaims,
getInstanceDisplays,
getVerifiedArtwork,
gatewaySource,
previewClaim,
Expand Down Expand Up @@ -72,6 +77,13 @@ const $itemSupply = getEl<HTMLSpanElement>("item-supply");
const $itemImageHex = getEl<HTMLSpanElement>("item-image-hex");
const $itemImageText = getEl<HTMLSpanElement>("item-image-text");
const $missingTag = getEl<HTMLSpanElement>("missing-tag");
const $instancesAsked = getEl<HTMLSpanElement>("instances-asked");
const $instancesFound = getEl<HTMLSpanElement>("instances-found");
const $instancesTargets = getEl<HTMLSpanElement>("instances-targets");
const $instanceName = getEl<HTMLSpanElement>("instance-name");
const $instanceTransferability = getEl<HTMLSpanElement>("instance-transferability");
const $instanceAttributes = getEl<HTMLSpanElement>("instance-attributes");
const $instanceMissingTag = getEl<HTMLSpanElement>("instance-missing-tag");
const $creditsBlock = getEl<HTMLSpanElement>("credits-block");
const $creditsCount = getEl<HTMLSpanElement>("credits-count");
const $creditsStates = getEl<HTMLSpanElement>("credits-states");
Expand All @@ -89,6 +101,19 @@ function log(msg: string, level: Parameters<typeof appendLog>[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;
Expand Down Expand Up @@ -306,6 +331,60 @@ async function readPreview(collections: number[], credit: string): Promise<void>
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<void> {
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<void> {
if (!chain) return;
$btnRefresh.disabled = true;
Expand Down Expand Up @@ -341,6 +420,8 @@ async function read(): Promise<void> {
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),
Expand Down Expand Up @@ -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;
};
}
}
Expand All @@ -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"));
88 changes: 88 additions & 0 deletions product-sdk/packages/nfts/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ import type {
RawCollection,
RawItemDef,
RawMetadataEntry,
RawMetadataLayers,
RawMetadataQuery,
} from "./types.js";
import type {
Claimant,
Expand Down Expand Up @@ -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<Vec<{ resolved?, collection, item, instance }>, 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<Array<RawItemDef | undefined>>;
};
};
};
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<RawMetadataLayers[], { type: "TooLarge"; value: { max: number } }>
>;
};
};
};
raw: {
assetHub: BlockSource;
};
}

/**
* The second chain a credits read needs, beside {@link NftsChain}.
*
Expand Down
29 changes: 25 additions & 4 deletions product-sdk/packages/nfts/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading