Skip to content

Native NFT Collections UI: gallery, detail/verify, mint-from-file, gift - #26

Draft
RhettCreighton wants to merge 14 commits into
masterfrom
feature/nft-gallery
Draft

Native NFT Collections UI: gallery, detail/verify, mint-from-file, gift#26
RhettCreighton wants to merge 14 commits into
masterfrom
feature/nft-gallery

Conversation

@RhettCreighton

@RhettCreighton RhettCreighton commented Jun 6, 2026

Copy link
Copy Markdown

Native NFT Collections UI — gallery, detail/verify, mint-from-file, gift

The native (no web browser) wallet UI for the ZSLP NFT feature. Pairs with the daemon PR ZclassicCommunity/zclassic#131. All rendering and verification happen in-app; nothing is ever auto-fetched.

What's here

  • Gallery (Collections tab) over the existing NFTImageCache/ContentEngine (one engine; additive posterForToken/posterReady). Honest per-state cards + tooltips (verified / mismatch / pending / image-not-on-this-computer), flicker-free re-feed, index-off page with a copyable zslpindex=1 hint, first-run intro while empty.
  • Detail / verify — large verified image; a verify badge that says only "matches / does NOT match its on-chain fingerprint" (never genuine/official — disambiguated via What's-This). "Attach the file you have…": pick a local file (remote/URL rejected for privacy) → stream-hash → match-gate (cache + go green only when the computed anchor equals the on-chain document_hash); non-match shows red and caches nothing; a hash-less NFT is honestly "cannot be verified."
  • Mint-from-file — drag a file → local fingerprint (never uploaded) → name → Create; permanence/public-ledger warning; Public-default; visible 0-conf success (Create→Done, never auto-accept); Cancel/[X] disabled while in flight.
  • Gift/send — live recipient validation; shielded address rejected; mismatch send-guard (a verifyState==2 item keeps Send disabled even with a valid t-address).
  • rpc.{cpp,h} mintNFT/sendNFT/nftProvenance/txReceivedDate with calm honest error mapping and ZCL_WIDGET_TEST-only test seams (no production behavior change). Async callbacks are QPointer-guarded (no use-after-free). C++14.

Tests

L0 140, L1 56 (1 pre-existing perf skip). 12 new NFT widget tests — mint gating / remote-drop reject / 0-conf-terminal / closeEvent-in-flight; send 4-state validation + the verifyState==2 mismatch send-guard; detail verified/mismatch badge copy + attach match→verified-and-cached / non-match→red-not-cached. Offscreen bundle delivery gate green.

Known follow-ups (tracked)

  • Sell/Buy + private-send (Shield) dialogs — the daemon RPCs exist (nft_*, z_senddatafile), GUI wrappers pending.
  • Honesty fix: remove the "Private — ownership is shielded" pill (ZSLP NFT ownership is always public/transparent) and drop the misleading "Send anyway?" mismatch prompt (Send is hard-disabled on mismatch).
  • Build hygiene: run-l0-l1 should qmake a clean tests/widget/bin before make (regenerates ui_settings.h).

🤖 Generated with Claude Code

Rhett Creighton and others added 2 commits June 6, 2026 02:09
Add a NATIVE Qt Widgets NFT gallery (no QtWebEngine / WebView / HTML —
QPainter + QListView only), fixture-driven and fully off the money path.

New components (src/):
- nft.h               POD NFTItem (name/collection/txid/docHashHex/cachePath/
                      receivedHeight/isPrivate/verifyState).
- nftgallerymodel.*   QAbstractListModel with custom roles and a fingerprint-
                      guarded setItems() (mirrors TxTableModel's flicker-free
                      refresh); onImageReady() slot updates matching rows.
- nftgallerydelegate.* QStyledItemDelegate painting a rounded card (dark.qss
                      tokens) + thumbnail / shimmer placeholder + privacy pill +
                      verify badge (tinted-SVG mask technique reused from
                      PrivacyBadgeDelegate); fixed sizeHint(168x208)*DPR.
- nftimagecache.*     bounded QThreadPool(4) pipeline: read bytes -> SHA-256 vs
                      docHashHex -> verifyState; QImageReader decode (scaled-
                      decode for >4096px / >10MB) -> thumbnail -> atomic PNG
                      write to AppData/nft_thumbs -> QPixmapCache(128MB). The
                      worker produces ONLY a QImage; the QPixmap is built on the
                      GUI thread in deliver() (queued invoke), so QPixmap is
                      never touched off-thread. In-flight QSet dedupes.

Wiring (surgical, indices kept consistent):
- mainwindow: setupNFTTab() builds a QListView (IconMode/Adjust/uniform/static)
  + delegate + model, inserts a "Collections" tab AFTER Transactions (index 4),
  feeds fixtures lazily on first reveal. setupNavRail() adds exactly one
  makeRailButton("Collections", <liveIdx>). Gated on getShowNFTGallery().
- rpc.cpp: the zclassicd "Advanced" tab no-double-add sentinel switched from the
  index-fragile widget(4)==nullptr to indexOf(zclassicdtab) < 0, so inserting
  the gallery at index 4 cannot break the Advanced page.
- settings: getShowNFTGallery()/setShowNFTGallery() (default ON).

Assets/theme:
- application.qrc: 3 verify SVGs (check/x/question, 16px) + 4 sample PNGs.
- dark.qss: NFT page/heading/subhead + borderless gallery view rules (existing
  tokens only).
- fixtures: 4 NFTItems — 2 verified, 1 deliberately-wrong-hash MISMATCH (red x),
  1 missing-bytes PENDING (amber ?).

Tests: 4 new L0 cases (tst_logic) — fingerprint-guarded no-op refresh, custom
roles + onImageReady verify-state transitions, stable sizeHint, and a full
threaded decode+verify pipeline (verified/mismatch/pending + atomic cache
write). L0 green: 108 passed / 0 failed. Build not run for the full app.

Pure GUI, zero chain dependency, not money-path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gift

Native (no web browser) NFT UI for the ZSLP NFT feature (daemon side:
ZclassicCommunity/zclassic#131). All rendering + verification is in-app.

- Collections tab gallery: NFTGalleryModel + delegate over the existing
  NFTImageCache/ContentEngine (one engine, posterForToken/posterReady — no
  second thread pool). Honest per-state cards/tooltips (verified / mismatch /
  pending / image-not-on-this-computer); flicker-free re-feed; index-off page
  with a copyable `zslpindex=1` hint; first-run intro shown only while empty.
- NFTDetailDialog: large verified image/poster; a verify badge that states
  ONLY "matches / does NOT match its on-chain fingerprint" (never
  genuine/official/authorized — disambiguated via What's-This). "Attach the
  file you have…" affordance: pick a LOCAL file (remote/URL rejected for
  privacy) -> stream-hash -> MATCH-GATE (cachePut + re-poster only when the
  computed anchor == the on-chain document_hash) so a RECEIVED NFT can reach
  the green badge; a non-match shows red and caches nothing; a hash-less NFT
  is honestly "cannot be verified" (never green). No auto-fetch ever.
- NftMintDialog: drag a file -> local fingerprint (never uploaded) -> name ->
  Create; permanence/public-ledger warning; Public-default (Private disabled
  coming-soon); visible 0-conf success (Create -> Done, never auto-accept);
  Cancel/[X] disabled while in flight (no use-after-free).
- NFTSendDialog: live recipient validation; shielded address rejected
  ("private gifts coming soon"); mismatch send-guard (verifyState==2 keeps
  Send disabled even with a valid t-address — "we won't send a forgery").
- rpc.{cpp,h}: mintNFT / sendNFT / nftProvenance / txReceivedDate wrappers;
  calm honest daemon-error mapping; test-injection seams (ZCL_WIDGET_TEST only,
  zero production behavior change).

Lifetime-safe (QPointer-guarded async RPC callbacks). C++14.

Tests: L0 140, L1 56 (1 pre-existing perf skip). 12 new NFT widget tests —
mint gating / remote-drop reject / 0-conf-terminal / closeEvent-in-flight;
send 4-state validation + the verifyState==2 mismatch send-guard; detail
verified/mismatch badge copy + attach match->verified-and-cached /
non-match->red-not-cached. Offscreen bundle delivery gate green.

Known follow-ups: Sell/Buy + private-send dialogs (daemon RPCs exist); the
"Private — ownership is shielded" pill is an overclaim to remove (ZSLP NFT
ownership is always transparent/public); the detail "Send anyway?" mismatch
prompt should drop (Send is hard-disabled on mismatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@RhettCreighton RhettCreighton changed the title feat(nft): native Collections gallery shell (Phase C0) Native NFT Collections UI: gallery, detail/verify, mint-from-file, gift Jun 6, 2026
Rhett Creighton and others added 12 commits June 6, 2026 21:22
Native (no web browser) NFT trade UI on top of the daemon nft_* offer RPCs
(ZclassicCommunity/zclassic#131), plus the ZcashFoundation#119 honesty fixes so the GUI never
overclaims privacy.

Sell/Buy:
- NFTSellDialog: compose (price ZCL + expiry + buyer t-address) -> nft_makeoffer
  -> LISTED state with the base64 offer blob (Copy / Save *.znftoffer) + Cancel
  listing (nft_canceloffer). List is gated on a valid price + buyer address; a
  mismatch item (verifyState==2) hard-disables List. Public-settlement framing
  stated plainly (price + both addresses are on-chain).
- NFTBuyDialog: paste/open an offer blob -> MANDATORY nft_verifyoffer (crypto +
  live-UTXO) before Buy is enabled; verify-fail shows an amber reason and keeps
  Buy disabled; editing after verify resets the gate; Buy -> nft_takeoffer.
- rpc.{cpp,h}: makeOffer / verifyOffer / takeOffer / cancelOffer wrappers with
  calm honest daemon-error mapping; ZCL_WIDGET_TEST test seams (no prod change).
- Lifetime-safe: [X]/Cancel swallowed while an RPC is in flight (QPointer +
  closeEvent guard), mirroring NFTSendDialog. C++14 (empty-QString sentinels).

Honesty (ZcashFoundation#119):
- nft.h: isPrivate now defaults FALSE (ZSLP NFT ownership is always public).
- nftdetaildialog: removed the false "Private - ownership is shielded" pill and
  dropped the misleading "Send anyway?" mismatch prompt (Send is hard-disabled
  on a fingerprint mismatch; there is no override).

Tests: L0 140/0/0, L1 69 passed / 0 failed / 1 pre-existing perf skip. 9 new
NFT widget tests: sell price-gating + ZCL->zat conversion + mismatch-disables-
list + success-blob-copy/save/cancel; buy verify-green-enables / verify-fail-
amber-disabled / edit-resets-gate / success->Done / close-swallowed-in-flight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ance

GUI build #3 (daemon side: ZclassicCommunity/zclassic data-channel RPCs).
Native, no-web-browser private file transfer over the ZDC1 shielded data
channel, built on a new shared async-dialog base. Both reviewers approved
(0 blockers). L0 155/0, L1 81 (80 pass / 1 pre-existing perf skip).

DRY base (audit C-1/C-2/C-4, A-1):
- NftAsyncDialog (src/nftasyncdialog.*): single-sources the load-bearing async
  scaffold — in-flight latch, primary-button relabel/disable, QPointer-guarded
  completion, Done/Try-again terminal states, and the closeEvent() that swallows
  [X] while a request is in flight (the prior UAF guard — now impossible to omit).
  Mint/Send/Sell/Buy refactored onto it; their copy-pasted scaffolds deleted.
- nftcommon.*: shared nftValidateTAddrInto() (4-state t-addr validator) +
  nftPublicTradeNote() (the public-trade honesty sentence) — single-sourced
  across send/sell/buy so the wording can't drift.
- Settings::zatToDecimalString() replaces NFTBuyDialog::humanZcl (bespoke money
  formatter) and routes through the tested getDecimalString (L0-covered).
- rpc.cpp nftListOffers stops sending the dead {"mine":...} wire field.

Shield (NATIVE_DISPLAY_UX.md §6, PRIVACY_TECH.md):
- rpc.{cpp,h}: sendDataFile / listDataTransfers / getDataTransfer /
  nftListTransfers wrappers (ZCL_WIDGET_TEST seams); datachannelCalmError maps
  the default-OFF -32601 to a calm "enable in Settings, then restart" hint.
- ShieldSendDialog (inherits NftAsyncDialog): local file -> binary-safe hex,
  40000-byte cap enforced up front, own-Sapling From combo, live zs-only
  recipient validation, MANDATORY permanence consent gating Send, success shows
  the copyable content fingerprint + per-transfer disclosure key with honest
  "public ciphertext forever / share the key only with intended readers" framing.
- ShieldReceiveDialog: list (honestly session-scoped) + cross-wallet open by
  transfer_id|fingerprint(+address); verify-before-decrypt; four DISTINCT honest
  failure states (HASH_MISMATCH/NO_KEY/AEAD_FAIL/INCOMPLETE), never a generic
  "failed", no fake "try again" on a hard refusal; Save enabled only after
  verify; binary-safe QByteArray::fromHex (never via QString).
- Settings toggle "Enable private file transfers": embedded daemon writes
  datachannel=1/0 to zclassic.conf + restart prompt; external node persists
  intent + the calm -32601 path. Honest copy (permanence restated on enable AND
  disable; disabling cannot delete already-sent files).
- Provenance: NFTDetailDialog shows the PUBLIC transfer history (nftListTransfers
  -> zslp_listtransfers); honest that ownership is always public.
- Entry points: Collections tab + detail dialog "Send/Open private file".

Honesty: no "ZEC"/"Zcash" in user copy; ownership-always-public stated
throughout; "private" scoped to file CONTENT; green = "bytes match fingerprint"
only, verified before any decrypt. C++14 (no std::optional/string_view;
header-signature includes in headers); SVG image/icon plugins imported.

Tests: 14 new cases — shield recipient 4-state validation, file>40000 reject,
permanence-consent gate, cross-wallet-by-fingerprint, verify-before-open shows
no plaintext on mismatch, [X] swallowed in flight; L0 zatToDecimalString; 2
DRY-drift guards (public-note + validator identical across dialogs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The NFT feature branches are a strict superset of master (which carries the
beta7 daemon fixes: -addnode connectivity, SIGBUS, updater); the version label
just hadn't been bumped. This is the beta7 line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the two issues hit when running the bundle against a pre-existing node.

BUG 1 — "method not found" on Create. The wallet attaches to an already-running
node and previously ASSUMED it had the bundled NFT feature set; an older
(v2.1.2-beta6) node has no zslp_*/nft_*/z_*datafile RPCs, so mint dead-ended on
a raw -32601 "method not found".
- RPC::probeNFTCapability(): on (re)connect, call zslp_listtokens and classify
  ONLY JSON-RPC -32601 as "no NFT support"; success or any other code (e.g. the
  index-off -1) => supported; transient/network => fail-open. NOT version-based
  (the embedded daemon briefly mis-reported its version yet has the RPCs).
- When unsupported: the Collections tab shows an honest guidance panel (quit any
  other running ZClassic node and restart to use the built-in node v2.1.2-beta7+,
  or upgrade your node) and the Mint/Sell/Send-private entry points are disabled
  with a matching tooltip.
- zslpCalmError() now maps -32601 -> that guidance for EVERY NFT/zslp wrapper
  (mint/send/provenance/makeOffer/verifyOffer/takeOffer/cancelOffer/listOffers/
  listTransfers); datachannelCalmError() maps it to the guidance on an
  unsupported node, else the "enable private file transfers in Settings" hint.
  No raw "method not found"/-32601 path remains.

BUG 2 — buttons/dialogs too big for small screens.
- New reusable wrap-to-next-line FlowLayout (src/flowlayout.{h,cpp}); the
  Collections heading is on its own line and the 4 action buttons wrap below it,
  so they fit any width >= one button (was a non-wrapping QHBoxLayout that
  overflowed/clipped on a narrow window). Buttons keep their member pointers so
  the capability gating still disables them.
- NFTDetailDialog dropped its hard 760x560 minimum and wraps its body in a
  QScrollArea (named "nftDetailScroll"), clamping the open size to fit small
  screens.

Tests: L0 136/0, L1 85 pass / 0 fail / 1 pre-existing perf skip.
- Tightened nftDialogs_fitSmallScreen (was tautological — passed on the broken
  dialog): asserts minimumSizeHint().width() <= 700, the scroll area exists, and
  the dialog actually shrinks to 600x420 with no clipping.
- New nftCalmError_minus32601MapsToGuidanceDirect: asserts zslpCalmError(-32601)
  == nftUnsupportedGuidance() verbatim (no "method not found", no -32601, no
  ZEC/Zcash) and that a different code does NOT map to the guidance.

GUI repo only; daemon untouched. C++14.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n gettoken fan-out (C-3)

- A-1: remove the mineOnly arg from RPC::nftListOffers (decl + def) and the dead
  `mine` field on the wire (the daemon no longer accepts it).
- C-3: refreshNFTs now reads name/ticker/tokenid/documenthash/genesisheight
  straight off the enriched zslp_listmytokens response (daemon A-3), deleting the
  Stage-2 per-token zslp_gettoken fan-out — one RPC round-trip per gallery
  refresh instead of 1+N. No UI field lost.

L0 136/0, L1 85/0 (1 pre-existing perf skip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ZcashFoundation#125)

Whole-app pass from UX_POLISH_PUNCHLIST.md (74 items). Honesty preserved verbatim
in meaning everywhere (adversarial honesty review: 0 open blockers). L0 136/L1 85
(5 widget tests updated for the new copy).

Don't-make-me-think:
- Settings opens on Options, not Troubleshooting; the connection tab is renamed
  "Advanced" with a one-line "most people never need this" header.
- "zclassicd" jargon removed from money screens (no-peers warning, tab title,
  delete-shielded/restart messages).
- Collections entry buttons shortened + unified: Send file / Receive file / Buy /
  Make; empty state collapsed to one line (honesty moved to WhatsThis).
- Mint dialog: 3 stacked paragraphs -> one amber public/permanence line + one grey
  "file never uploaded" line. Detail view: 11-button cluster tamed (primary
  Send/Sell + a quiet utility row), one state-aware "Re-check"/"Check my file".
- Send privacy badge cue shortened; Settings helper paragraphs -> one line each
  (data-channel public/permanent honesty kept verbatim); sell expiry is a
  read-only line, not a 1-choice combo.

Honesty fix (code): delete the dead privacyLabel()/privacyColor() delegate helpers
that could return "Private"/green, contradicting the always-Public pill (ZcashFoundation#119).

Visual consistency: success/verified/valid-address green standardized to #34c759;
public = amber #d9822b; red reserved for de-shield/mismatch; honesty fine print
bumped off 11px to >=12pt; "collectible" (not "NFT") in visible labels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation#124)

Caught by the headless end-to-end test (real bundle, offscreen, real regtest NFTs).

Fit any screen (the "too big for my screen" class):
- The main window stuck at ~968px and clipped on small screens. Root cause: the
  Send page's non-wrapping bottom row (fee/totals/Review-&-send/Cancel,
  horizontalLayout_6) summed its children's widths, and an AdjustToContents
  scroll area refused to shrink — making Send the widest tab page and pinning the
  whole QTabWidget's minimum. Fix: re-home that row into a wrapping FlowLayout and
  let sendToScrollArea shrink (Ignored hpolicy, min 0). Window now fits 820px,
  honoring the deliberate 648 floor. Verified: winw==screen at 900x600 and 820x560.

Honest gallery art (no network, no overclaim):
- Cards with no local image bytes paint a deterministic hash-art tile derived from
  the on-chain fingerprint + a one-line "Tap to add image" affordance — NO download
  verb, NO fetch (privacy hard rule). Real bytes are resolved OFFLINE from files the
  user holds or bundled sample resources whose bytes hash to the fingerprint, so a
  verified (green check = bytes match fingerprint) image renders without leaking.

Robustness + test seams (harmless in normal use):
- logger: mkpath the log dir so it opens in a fresh profile (was "device not open").
- ZQW_UITEST_TAB=collections deterministically lands the offscreen E2E on Collections.
- probeNFTCapability + foreign-node attach now also log their verdict to stderr.

Headless E2E proof: real GUI attaches to a regtest NFT node, 2 CLI-minted NFTs
render with Public pills + verified checks, buttons fit, no "method not found".
L0 136/0, L1 85/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, Browse/Search

Three new GUI pillars completing the decentralized-platform user journeys, all headless-tested at L1 (offscreen tst_widget: 122 passed / 1 pre-existing fail / 1 skip; +26 new pillar tests, all green).

Names (ZNAM): a Names tab + Register/Resolve/Transfer dialogs + a My-Names list that lazy-loads on tab open (live-E2E fix: it previously only refreshed after a dialog closed). 4 name_* RPC wrappers with a ZCL_WIDGET_TEST seam; target_type as a JSON int; name_listmine called with empty params (daemon self-enumerates the wallet). 11 names_* tests.

My-Listings: NFTListingsDialog (view my sell offers, cancel open ones), pure reuse of nft_listoffers/nft_canceloffer; matches role=="sell" literally. 7 myListings_* tests.

Browse/Search: NFTMarketDialog + nftBrowseOffers wrapper (nft_browseoffers, RPC_METHOD_NOT_FOUND -> calm), client-side name/collection filter, buy hand-off into the proven NFTBuyDialog via openWithOffer (full znftoffer: blob through the mandatory pre-pay verify gate). 8 browse_* tests.

Honest UX: -znamindex/-nftmarket hints; 'discovery off until Tor relay ships' empty state. Coin is ZCL (never ZEC). GUI talks to daemon only via JSON-RPC — no consensus surface. C++14. Pairs with daemon hardening ff46da8fe. Live integrated E2E confirmed full app links + Names/Collections journeys work GUI<->daemon over the real wire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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