Skip to content

feat(nfts): collections - #329

Open
piggydoughnut wants to merge 10 commits into
mainfrom
feat/nfts-collections
Open

feat(nfts): collections#329
piggydoughnut wants to merge 10 commits into
mainfrom
feat/nfts-collections

Conversation

@piggydoughnut

@piggydoughnut piggydoughnut commented Aug 26, 2026

Copy link
Copy Markdown

Part of #318.

Adds @parity/product-sdk-nfts: catalogue reads of the Scarcity pallet on Asset Hub. Every value comes from storage, pinned to one finalized block per call.

Pagination

DEFAULT_PAGE_LIMIT (100) and caps at MAX_PAGE_LIMIT (1000) - both exported.

in out
limit, fromId idCeiling, nextId

Pagination walks the sequential u32 index space each read enumerates, which is sound because the runtime guarantees the shape: ids and indices come from counters that only move forward, and both delete_collection and delete_item document that identifiers are never reused.

Shared options

Every read takes these. getCollectionItems adds attributes.

Option Type Default Notes
limit number 100 Entries this page returns. Max limit is set to 1000; a larger request is capped by max limit rather than fail, and nextId still reports where the page stopped.
fromId number 0 Where the window starts. Take it from the previous page's nextId.
at FinalizedSnapshot Address a block a previous read already pinned, instead of pinning a new one.
signal AbortSignal Forwarded into every underlying pull.

at is what makes a walk coherent. Without it every call pins its own finalized block, right for unrelated questions, wrong for one question asked in pages, since a walk over its own snapshots is not a walk of any single chain state. Pass another result's at straight back in. It also makes two reads agree: the registry and the full listing, or a listing and a catalogue, at one block.

API functions

Each pins its block first through raw.assetHub.getFinalizedBlock() (unless given at), then addresses storage at that hash, so all values in one result come from one block. Each returns a Result, per the SDK-wide error model.

getCollections(chain, options?)

Result<CollectionsResult, ProductNftsError>

interface CollectionsResult {
    at: FinalizedSnapshot;
    collections: Collection[];    // { id, name, itemCount, owner, selection }
    idCeiling: number;            // exclusive end of the collection id space
    nextId: number | null;
}

Every collection on chain, ascending by id, a page at a time. selection: null means the collection exists but accepts no claims.

Call Shape Count
raw.assetHub.getFinalizedBlock() pin the block, unless at was given 1
query.Scarcity.NextCollectionId.getValue(at) the id ceiling 1
query.Scarcity.Collections.getValues(window) the window's records 1 (+1 per stretch of deleted ids)
query.NftClaims.CollectionMinters.getValues(page ids) selection for the page 1
query.Scarcity.CollectionMetadata.getValues(page ids × "name") exact-key names 1

getClaimableCollections(chain, options?)

Result<ClaimableCollectionsResult, ProductNftsError>

interface ClaimableCollectionsResult {
    at: FinalizedSnapshot;
    collections: ClaimableCollection[];  // { id, name, itemCount, owner, selection }
    idCeiling: number;
    nextId: number | null;
}

The subset getCollections filters: collections registered to accept claims, ascending by id.

Call Shape Count
raw.assetHub.getFinalizedBlock() pin the block, unless at was given 1
query.Scarcity.NextCollectionId.getValue(at) the id ceiling 1
query.NftClaims.CollectionMinters.getValues(window) which ids are registered 1 (+1 per stretch of unregistered ids)
query.Scarcity.Collections.getValues(page ids) the records, batched 1
query.Scarcity.CollectionMetadata.getValues(page ids × "name") exact-key names 1

getCollectionItems(chain, id, options?)

Result<CollectionItemsResult, ProductNftsError>

type CollectionItemsResult =
    | { tag: "Found"; at: FinalizedSnapshot; idCeiling: number; nextId: number | null;
        collection: CollectionDetail }   // { id, name, itemCount, items }
    | { tag: "NotFound"; at: FinalizedSnapshot; id: number };

One page of a collection's item catalogue, items ascending by index. Applies no registry filter, so it reads a collection that accepts no claims just as well. A collection nobody created is not an error; it rides the ok channel as NotFound.

idCeiling counts every item ever defined here (indices are never reused);
collection.itemCount counts the ones still alive. They diverge permanently once anything is deleted.

Extra option:

Option Type Default Notes
attributes boolean false Return the open metadata.
Call Shape Count
raw.assetHub.getFinalizedBlock() pin the block, unless at was given 1
query.Scarcity.Collections.getValue(id, at) the record: id ceiling + the NotFound test 1
query.Scarcity.CollectionMetadata.getEntries(id, at) the collection's defaults, to merge under 1
query.Scarcity.ItemDefs.getValues(window) the window's definitions 1 (+1 per stretch of deleted indices)
query.Scarcity.ItemMetadata.getValues(page × 3 named keys) name, image, rarity — when attributes is off 1
query.Scarcity.ItemMetadata.getEntries(id, at) every key of every item — when attributes is on 1

attributes is Record<string, string> | null, and null means "not fetched". An empty object would claim the item carries no metadata, which is a different statement. The typed fields (name, image, rarity) are keys this package can name, so a page fetches them for its whole window with one exact-key read; the bag's keys are open by definition, so there is nothing to ask for by name and filling it means scanning the collection. Collection-level defaults are inherited either way.

Other changes

packages/nfts/src/chain.ts — the client the reads take, typed structurally by the six storage entries they touch rather than by naming a descriptor, so no genesis hash is pinned to read a catalogue. It asks for exactly the accessors the reads use: making every read paged removed four of them. An app pruning its own descriptors must whitelist all six, Scarcity.NextCollectionId included.

packages/nfts/src/paging.tsDEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT, pageBounds, and fillByIdWindow, shared by all three reads. They differ only in what makes an index interesting (a collection record, a minter entry, an item definition), so the density widening, scan budget and cursor semantics live in one place.

packages/nfts/src/metadata.ts — the decode convention. Metadata is untyped Vec<u8>Vec<u8> in three layers, each overriding the last; a catalogue read merges the first two. Values decode as UTF-8 when the bytes are readable text and 0x-hex otherwise. Exact-key reads take a plain Uint8Array — PAPI 2.x generates [number, Uint8Array] for this Vec<u8> key.

packages/nfts/src/errors.tsNftsChainEntryError names the storage entry a read could not reach, carries it on entry, and keeps PAPI's error as the cause.

packages/sdk/* — the @parity/product-sdk/nfts subpath, plus src/nfts/contract.test.ts: compile-time assertions that a real getChainAPI client satisfies NftsChain, with devnet Asset Hub as a negative control. These run under pnpm typecheck, not vitest.

examples/nfts-demo/* — demo app plus Playwright specs. The panel for getCollections renders the ids with selection: null next to the registry's, because the gap between the two lists is the thing worth seeing on live data. It also walks the id space in pages of 2 with at pinned, and the spec asserts two things a type cannot: that names from a small-page walk match a single larger page (parameter bivariance means the contract check would accept a wrong exact-key type, and a wrong key silently returns null for every name), and that the whole walk touched exactly one block.

skills/product-sdk-nfts/SKILL.md, CLAUDE.md, .claude-plugin/marketplace.json, README.md, .changeset/config.json — the skill and its registration, the new package's row in the package table, and @parity/product-sdk-nfts-demo added to the changeset ignore list alongside the other demos. Plus one changeset.

Notes

Nothing on chain declares the metadata keys or the value types, so name, image and rarity are a convention this package applies, not a schema it enforces; every other key is reachable through attributes. Unconfirmed with the pallet team.

imageRef reports the same bytes as hex and as text (null when unreadable): one deployment stores a 32-byte content digest there, another an ASCII CID, and nothing on chain says which, so the caller picks.

transferability is not returned. It traces to pallet_nfts' CollectionSetting::TransferableItems and has no source in Scarcity, not in ItemDefs, not in any metadata key the live chain carries.

@piggydoughnut
piggydoughnut marked this pull request as draft August 26, 2026 12:15
@piggydoughnut piggydoughnut changed the title WIP: Feat/nfts collections feat(nfts): collections Aug 28, 2026
@piggydoughnut
piggydoughnut marked this pull request as ready for review August 28, 2026 08:47
@piggydoughnut
piggydoughnut marked this pull request as draft August 28, 2026 10:31
@piggydoughnut
piggydoughnut changed the base branch from main to feat/nfts August 28, 2026 12:11
@piggydoughnut
piggydoughnut changed the base branch from feat/nfts to main August 28, 2026 12:19
@piggydoughnut
piggydoughnut changed the base branch from main to feat/nfts August 28, 2026 12:21
@piggydoughnut
piggydoughnut changed the base branch from feat/nfts to main August 28, 2026 13:32
@piggydoughnut
piggydoughnut marked this pull request as ready for review August 31, 2026 14:13
@piggydoughnut
piggydoughnut requested a review from TarikGul August 31, 2026 14:13
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

📦 Bundle size impact

Comparing 2026-09-01T08:50:24.294Z2026-09-01T08:50:22.041Z

Package Entry Bundled before Bundled after Δ Ship gzip Δ Shake ratio
🟢 @parity/product-sdk . 8.56 MB 8.57 MB +4.4 KB (+0.1%) +13 B 0% (was 0%)
🟢 @parity/product-sdk ./chain 8.32 MB 8.33 MB +4.4 KB (+0.1%) 0 B 0% (was 0%)
🟢 @parity/product-sdk ./cloud-storage 8.50 MB 8.50 MB +4.4 KB (+0.1%) 0 B 2% (was 2%)
🟢 @parity/product-sdk ./core 8.56 MB 8.57 MB +4.4 KB (+0.1%) +11 B 0% (was 0%)
🟢 @parity/product-sdk ./host 93.7 KB 98.3 KB +4.6 KB (+4.9%) 0 B 8% (was 9%)
🟢 @parity/product-sdk ./individuality 69.7 KB 70.1 KB +430 B (+0.6%) 0 B
🟢 @parity/product-sdk ./local-storage 60.1 KB 64.5 KB +4.4 KB (+7.3%) 0 B 100% (was 100%)
🟢 @parity/product-sdk ./nfts new entry
🟢 @parity/product-sdk ./react 8.57 MB 8.58 MB +4.4 KB (+0.1%) +12 B 0% (was 0%)
🟢 @parity/product-sdk ./testing 59.0 KB 59.1 KB +148 B (+0.2%) +24 B 26% (was 25%)
🟢 @parity/product-sdk ./wallet 190.5 KB 194.9 KB +4.4 KB (+2.3%) 0 B 1% (was 1%)
🟢 @parity/product-sdk-chain-client . 8.32 MB 8.33 MB +4.4 KB (+0.1%) 0 B 0% (was 0%)
🟢 @parity/product-sdk-cloud-storage . 8.50 MB 8.50 MB +4.4 KB (+0.1%) 0 B 2% (was 2%)
🟢 @parity/product-sdk-host . 93.7 KB 98.3 KB +4.6 KB (+4.9%) +36 B 8% (was 9%)
🟢 @parity/product-sdk-host ./testing 10.4 KB 10.5 KB +31 B (+0.3%) +39 B 65% (was 65%)
🟢 @parity/product-sdk-local-storage . 60.1 KB 64.5 KB +4.4 KB (+7.3%) 0 B 100% (was 100%)
🟢 @parity/product-sdk-nfts new package
🟢 @parity/product-sdk-signer . 190.5 KB 194.9 KB +4.4 KB (+2.3%) 0 B 1% (was 1%)
🟢 @parity/product-sdk-statement-store . 105.4 KB 110.0 KB +4.6 KB (+4.4%) 0 B 9% (was 10%)

Thresholds — 🟡 ≥10% or ≥5.0 KB · 🟠 ≥20% or ≥15.0 KB (bundled). Percentage only applies once the baseline is ≥ 10 KB. Informational — this check never blocks merge.

@TarikGul
TarikGul requested a review from a team September 4, 2026 13:59

@Imod7 Imod7 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The paging design is right, and resting it on the never-reused id space is the right reason. Three things worth settling before merge, each with the detail on its own line: unchecked ids, at packages/nfts/src/paging.ts line 93 and packages/nfts/src/items.ts line 186; and the unknown ItemSelection variant at packages/nfts/src/collections.ts line 208. One shape recommendation, on the readAllKeys line in packages/nfts/src/items.ts: keep attributes as an option and give it a named-key form.

The design fits the repo. The read signature, the SdkError model, the structural chain contract and the compile-time check in packages/sdk/src/nfts/contract.test.ts all match what @parity/product-sdk-individuality established, and the dependency set is leaner than the sibling's. Two things I expected to be divergences are not, the positional id argument and the get* naming. Two consistency points remain, neither blocking, with the detail on each line: packages/nfts/src/index.ts on a missing fromPapi, and packages/nfts/src/chain.ts on how wide NftsChain is.


// The record carries the index ceiling, so unlike the collection listing
// this read pays nothing extra to learn where the space ends.
const record = query.Scarcity.Collections.getValue(id, at);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please validate id before it becomes a storage key. PAPI's u32 encoder truncates rather than rejecting, so getCollectionItems(chain, NaN) returns collection 0's catalogue labelled id: NaN and 1.5 returns collection 1's, both on the ok channel as Found. Number.isInteger(id) && id >= 0 && id < 2 ** 32, returning err, closes it. fromId in packages/nfts/src/paging.ts line 93 needs the same check, so the predicate belongs there.

const from = usable(options.fromId) ?? 0;
return {
limit: Math.min(Math.max(0, Math.trunc(asked)), MAX_PAGE_LIMIT),
fromId: Math.max(0, Math.trunc(from)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fromId gets a floor but no ceiling, and the id space is u32. PAPI's encoder truncates, so getCollections(chain, { fromId: 2 ** 32 }) on a two-collection chain returns those two as ids 4294967296 and 4294967297, with nextId: null.

// Widen by the density seen so far, so a sparse range converges in a
// couple of reads rather than one read per gap.
const density = Math.max(kept.length / Math.max(scanned, 1), 1 / SCAN_BUDGET_FACTOR);
const width = Math.min(Math.ceil(want / density), want * SCAN_BUDGET_FACTOR);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This width is capped by the scan budget but not by MAX_PAGE_LIMIT, so one probe can ask for 15,000 keys at limit: 1000 on a sparse space. Since getValues is Promise.all(keys.map(getValue)), that is 15,000 concurrent operations against a documented bound of 1,000.
MAX_PAGE_LIMIT's own doc needs rewording too, since it names one bound where there are three.

// The one branch: named keys for the page, or the whole collection's item
// metadata when the open bag was asked for.
const overrides = options.attributes
? await readAllKeys(query, id, at)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readAllKeys runs even when the page found no items, so a page past the end still prefix-scans the whole collection to return nothing. readTypedKeys on the next line has that guard and this branch does not, so please skip the metadata read when filled.kept is empty. Separately, attributes is a per-page option with a per-collection cost, so a walk repeats the full scan every page. A getCollectionAttributes(chain, id, { at }) read would let a caller pay it once.

* }
* if (page.nextId === null) break;
* const next = await getCollections(chain, { limit: 100, fromId: page.nextId, at });
* if (!next.ok) break;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats a failed page as the end of the walk, so a caller copying it renders a partial list as complete. PAPI holds a pinned block for one storage read only, so a long at-pinned walk will lose it. Please report the failure rather than break, and say in the at doc that a walk has to re-pin. Four more sites: items.ts line 167, SKILL.md lines 98 and 154, and the changeset line 64.

ItemSelection,
ClaimableCollection,
Collection,
RawBytes,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four types in NftsChain's signature are not exported: RawCollection, RawItemDef, RawMinter and RawMetadataEntry. Importing them fails, and RawCollection gets the suggestion "Did you mean 'Collection'?", which is a different type and would give a wrong implementation. Adding them here is additive and clears four typedoc warnings.

* characters. Anything below U+0020 that is not tab/newline/carriage-return
* disqualifies it, as does a lone U+FFFD.
*/
function isText(decoded: string): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc above asks whether the string is safe for a UI, but the check covers only C0 controls, U+007F and U+FFFD. C1 controls, bidi overrides and zero-width characters pass, so a name containing U+202E renders as a different name. Metadata is author-supplied, so please either narrow the doc or widen the check.

* page. That costs an extra record read where holes appear and nothing else: a
* hole never gets a name or registry lookup. A page is short only at the end of
* the id space, or when a mostly-deleted range hits
* {@link SCAN_BUDGET_FACTOR}; `nextId === null` is the only end signal.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SCAN_BUDGET_FACTOR is not exported, so this link does not resolve and docs:extract warns. Exporting it beside DEFAULT_PAGE_LIMIT and MAX_PAGE_LIMIT fixes the link and gives callers the second half of the paging bound.


const { limit, fromId } = pageBounds(options);

if (limit === 0) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fillByIdWindow already handles limit: 0 and returns nextId: null, so this shortcut and its twin in readPage duplicate the helper. getCollectionItems has none and is correct without one. Dropping both costs one getValues([]), which does no network work.


// Both concurrently, for exactly the ids being returned — `chain.ts` has
// what a multi-key read costs.
const [records, names] = await Promise.all([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

readPage returns early when its window found nothing and this read does not, so an empty registry page still calls Collections.getValues([]). The same early return keeps the two in step. Separately, the three option interfaces each redeclare limit, fromId, at and signal; a shared PagedReadOptions would document them once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants