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.

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.

1 participant