Skip to content

inventory: tokens() skips any token with no assets, so a coinless token is invisible #781

Description

@MastaP

Problem

tokens(registry, filter) drops every mirror entry whose first asset is absent, so a coinless
token — one whose genesis data carries no value envelope — never appears in the wallet's token list.

dist/index.js of 0.16.0, :12985-12991:

tokens(registry, filter) {
  const out = [];
  for (const [tokenId, entry] of this.mirror) {
    if (entry.status !== "active") continue;
    const asset = entry.assets[0];
    if (!asset) continue;                    // <- a coinless token exits here
    if (filter?.coinId !== void 0 && asset.coinId !== filter.coinId) continue;
    

The guard is not itself wrong — toToken(tokenId, entry, asset, registry, …) takes asset as a
required argument, so skipping was the only safe thing to do when every indexed token was guaranteed to
have one. That guarantee no longer holds.

Why it changed

wallet-api ships coinless tokens as of unicity-sphere/wallet-api#140 (cb4c275, deployed). Such a
token is indexed as an ordinary active row with zero asset rows, and GET /v1/inventory returns it
with assets omitted and a new tokenType field. Verified end to end on staging: mint, deposit,
owner-to-owner transfer, claim, spend and restore all work.

The sync path already handles the new shape safely — applyOne (:13070-13082) normalises with
assets: item.assets ?? prev?.assets ?? [], so the mirror entry is always an array and nothing throws.
Confirmed by driving the shipped 0.16.0 client against deployed staging holding a coinless token: it
authenticates, syncs, parses the response including the unknown tokenType field (parseJsonBody is a
bare JSON.parse, no strict rejection), and reports correct balances. The token is simply absent
from tokens().

So this is a display gap rather than a correctness bug: the token is held, tracked in the mirror,
spendable and transferable through the lower-level paths. It just cannot be seen.

Fix

Make the asset optional through tokens() and toToken rather than filtering the entry out. Two
questions that belong to whoever owns the token model:

  1. filter.coinId. A coinless token matches no coin id. It should presumably be excluded when a
    coin filter is supplied and included when none is, which falls out naturally if the filter check
    moves inside an asset !== undefined branch.
  2. What the token carries instead of coinId/amount. wallet-api now indexes and returns
    tokenType — the 1–64 byte genesis type — precisely so a client can name the KIND of token it
    holds. Note it is a class label, not an identity: token_id is the instance key, and every
    token of one kind shares a type. Canonical per-network ids live in the upstream registry
    (unicity-ids.<network>.json, the assetKind: "non-fungible" entry — testnet2 971a26ee…,
    mainnet 9f190eea…), but a minter may use its own and the token is still valid, so a recognition
    table must be keyed by network and must not reject an unrecognised type.

Caveat for grouping: SphereTokenEngine.mint() derives tokenType per operation and split outputs
derive it per output ordinal, so for ordinary value tokens it is per-mint noise, not a class. Only
a caller-supplied type (as mintDataToken takes) is a stable class identifier.

Acceptance

  • A coinless token appears in tokens() when no coin filter is supplied.
  • A coin-filtered call still returns only tokens carrying that coin.
  • Valued tokens are unchanged.
  • Nothing throws for an entry with an empty asset list.

Related, not duplicate

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions