Skip to content

feat(wallet): show and send coinless tokens in the Tokens tab - #517

Open
MastaP wants to merge 5 commits into
mainfrom
feat/502-nft-tokens-tab
Open

feat(wallet): show and send coinless tokens in the Tokens tab#517
MastaP wants to merge 5 commits into
mainfrom
feat/502-nft-tokens-tab

Conversation

@MastaP

@MastaP MastaP commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Coinless tokens (NFTs) were invisible in the wallet, and once sphere-sdk#777 made them transferable there was still no way to move one.

What changed

Tokens tab lists coinless holdings alongside coin tokens, with an "NFTs only" toggle. A coinless row shows the class name and icon the registry supplies for its token type; an unrecognised type renders from the type itself and stays fully usable — the registry supplies a display name, never permission to show a token. No amount, no decimals: the token is the thing.

Assets tab is unchanged — it still aggregates coins only.

Every row gets a send action that moves that token whole: one named source, no split, so a valued token's coins travel with it. Coin rows route to sendWholeToken; coinless rows route to sendCoinless, which additionally refuses a valued source — so a row that is wrong about holding an NFT fails loudly instead of quietly moving coins.

Empty state counts both reads. A wallet holding only NFTs is not empty. tokens() and coinless() are disjoint, so nothing is counted twice.

Notes for review

useTransfer is parameterized, not forked. The subscription-key gate, the quota gate, the possibly-committed conversion that prevents double-pay, and the refetch fan-out all apply identically to a whole-token send, so the divergence is one branch at the SDK call. Forking it would have meant re-implementing the money safety.

The coinless query key nests under tokens.all, so the three refetch sites that already exist (top-up, transfer, swap) refresh it with no new plumbing.

Removes the coinId !== 'NAMETAG' filter (5 sites). The self-issued UnicityIdToken mint was removed with the 2.0.0 base-SDK bump; the only NAMETAG left in the SDK is AddressType = 'DIRECT' | 'NAMETAG' in core/address.ts, an address discriminator for parsing @alice that is never assigned to Token.coinId. The filter guarded a value that cannot exist.

Pins @unicitylabs/sphere-sdk 0.17.1.

Verification

  • tsc -b clean, lint 0 errors
  • 1184 tests pass (153 files), +12 new
  • Reviewed with codex exec review; both findings addressed — the modal state races are fixed and covered, each new test verified to fail without its fix

Closes #502

The Tokens tab rendered coin-shaped rows only, so a coinless token (an NFT)
was invisible — and once sphere-sdk#777 made it transferable there was still
no way to move one.

Tokens tab now lists coinless holdings alongside coin tokens, with an
"NFTs only" toggle. A coinless row shows the class name and icon the
registry supplies for its token type; an UNRECOGNISED type renders from the
type itself and stays fully usable, because the registry supplies a display
name, never permission to show a token. No amount, no decimals — the token
IS the thing. The Assets tab is unchanged: it still aggregates coins only.

Every row gets a send action that moves THAT token whole — one named source,
no split, so a valued token's coins travel with it. Coin rows route to
sendWholeToken; coinless rows route to sendCoinless, which additionally
refuses a valued source, so a row that is wrong about holding an NFT fails
loudly instead of quietly moving coins.

useTransfer is parameterized rather than forked: the subscription-key gate,
the quota gate, the possibly-committed conversion that prevents double-pay,
and the refetch fan-out all apply identically to a whole-token send, so the
divergence is one branch at the SDK call. The coinless query key nests under
tokens.all, so the refetch sites that already exist refresh it too.

The empty state now counts both reads — a wallet holding only NFTs is not
empty. tokens() and coinless() are disjoint, so nothing is counted twice.

Also removes the `coinId !== 'NAMETAG'` filter (5 sites). The self-issued
UnicityIdToken mint was removed with the 2.0.0 base-SDK bump; no such coinId
is emitted anywhere, so the filter only obscured the real predicate.

Pins @unicitylabs/sphere-sdk 0.17.1 (coinless(), tokenData(), sendWholeToken,
sendCoinless, TokenRegistry.getTypeMeta).

Closes #502
The modal is reused for every row, but nothing reset it when the target
changed. Two consequences, both wrong in the same way — one token's outcome
presented as another's:

- a send that resolved after the parent swapped targets wrote its success
  onto the new token, so the screen claimed a token had been sent that never
  left;
- a success screen from the previous token was still standing when the next
  one opened, so there was no form to send it with.

State now resets when the target changes, and each send resolves against the
generation it started in — a completion from a retired target is dropped
rather than painted onto whatever is showing.

Closing during a send is also refused now. The spend may already be on-chain,
and the modal is the only surface saying so; dismissing it would leave the
user believing nothing happened, which is exactly when someone sends again.

Tests cover both races plus the keep-open copy and the per-kind verb routing.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9f714cc118

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/components/wallet/L3/views/L3WalletView.tsx Outdated
A coinless arrival is announced in `coinless`, never in `tokens` — it carries
no coin. The incoming handler read only `tokens`, so an NFT summed an empty
list to 0 and took '?' for the symbol no NFT has, and the receive toast said
"+0 ?". It also published that zero as balance progress, promising a balance
move that was never coming.

Coinless arrivals now take their own path: the toast names what arrived (the
class name, or a count when several land from one sender) with no amount and
no symbol, and publishes no progress. An unrecognised token type still
announces — it degrades to "an NFT" rather than going silent.

Observed on staging receiving a real NFT.
Clicking any token row — coinless or coin — opens its genesis payload as hex,
alongside the full token id and, for a coinless token, its class.

One call serves both kinds because it returns whatever the minter wrote: for
a coinless token that is its data; for a coin token it is the value envelope
(CBOR tag 39050), so the hex decodes to the coin id and amount in any CBOR
parser. Verified on staging — a 4200-unit mint reads back as
d9988a...421068f6, where 0x1068 is the amount.

Shown raw rather than decoded on purpose: the wallet has no schema for an
arbitrary minter's payload, and rendering a guess would be worse than
rendering the bytes. A read failure is surfaced as an error rather than as
"no data" — readTokenData throws for a token not held or with no blob, and
reporting that as empty would misdescribe what the token carries.

Fetched only when a token is actually opened: the blob is a round trip each.
The toggle rendered only while the wallet held a coinless token, but the
filter state outlived that condition: sending the last NFT with the filter on
left nftOnly true and the toggle gone, so the remaining coin tokens were
hidden with no control left to unhide them. The empty state then claimed "No
individual tokens found" for a wallet that held several.

The toggle now stays rendered whenever the filter is on, and the empty copy
names the filter as the reason rather than describing the wallet wrongly.

The decision moved into tokensTabView(), a pure function over (coinless,
coins, nftOnly) — the stranding case is a state combination, and it is worth
being able to assert it directly instead of through the view.
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.

feat(wallet): show non-fungible tokens in the Tokens tab

1 participant