- LazyMount: mount a grid card only when it scrolls within 600px (skeleton-space
placeholder otherwise), so a long feed no longer mounts every card + its
hooks/DOM up front. Falls back to immediate render without IntersectionObserver.
- GearSwapItemCard: clicking the card now opens /listing/:pubkey/:identifier
(was opening a DM). 'Contact Seller' still opens the DM. Card set to h-full so
the lazy wrapper keeps the equal-height grid.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
On the listing detail page, buyers get 'Make an offer' (opens the DM with the
seller pre-filled with an offer message about the listing) and 'Share listing'
(copies the URL). Replaces the redundant 'Message' button (Contact Seller is
already in the shared trade actions).
Gate: 101 tests, 0 errors/warnings, boot smoke green.
On /trades, each authored trade record gets a 'Revoke' action (with a confirm
dialog) that republishes the 31871 with s=revoked — retracting it from
reputation for a disputed or mistaken deal. The counterparty keeps their own
attestation. Revoked records are already excluded from aggregation.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- New '/trades' page: action-needed (incoming 'confirm this sale' requests as
seller, pending confirmations as buyer) with inline actions, a reputation
card, and the user's own trade records (role/counterparty/state) linking to
each listing. Nav entry (auth-only) + smoke route.
- DM inbox relays (kind 10050) are now block-filtered on both read and delivery
(closes the last relay caveat).
- AGENTS.md updated.
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- Mints page: 'Online only' toggle + result count ('N of M mints')
- Wallet page: 'Browse Cashu mints' link to /mints
- ListingTradeActions now renders the EscrowPanel for a viewer's active escrow,
so escrow status shows on both the grid card and the detail page
- AGENTS.md project map: marketplace detail page + shared actions
Gate: 101 tests, 0 errors/warnings, boot smoke green.
So Local relays are used and Blocked relays are excluded EVERYWHERE, not just in
the NPool routers:
- useListingReports, useSellerInfo, useSkateRelays (read+write), useZaps
(NIP-57 relays tag), usePurchasePro, CollectionDetailPage, CrewForm,
useDirectMessages write fallback
- AppProvider's empty-relays defaults check is the only intentional direct reader
- AGENTS.md updated
Gate: 101 tests, 0 errors/warnings, boot smoke green.
- New '/listing/:pubkey/:identifier' page: large gallery + lightbox, full
description, details, seller trust + trade reputation card, and full actions
(shared) for buyers and owners (mark sold/relist/delete)
- Extract ListingTradeActions (confirm/escrow request/ask-to-confirm/refund/
contact + escrow dialog) and use it in BOTH the grid card and the detail page
so there is one implementation
- useMarketplaceItem(pubkey, identifier) fetch hook
- Cards link their image + title to the detail page
- Smoke test covers /listing/:pubkey/:identifier
Gate: 101 tests, 0 errors/warnings, boot smoke green.
The last 7 react-refresh warnings are dev-only HMR DX from intentional
component+hook/helper colocations (EventCountdown, SortDropdown, TrustFilter,
LanguageContext, NewsletterPage). Splitting them into new files is broad import
churn for no runtime benefit, so each carries a justified file-level disable.
npm test: 0 errors, 0 warnings, 101 tests; boot smoke green.
- RelayMetadata.dmRelays; Relays UI to add/remove, publishing kind 10050
- NostrSync fetches the user's 10050 on login (alongside 10002)
- DMs deliver gift wraps to recipients' + own DM relays (fallback: write relays)
and read from own DM relays + NIP-65 read relays
- useDmRelays() to look up others' DM relay lists
Also fixes a latent data-loss bug: NostrSync's 10002 fetch replaced
relayMetadata wholesale, which would wipe the new local/blocked/dm lists on
login — it now merges. 'Trusted' category intentionally skipped (no distinct
behaviour -> no empty UI); documented in AGENTS.md.
Add a purposeful subset of Amethyst-style relay categories (not a full clone):
- App-local 'Local relays' (never published; added to reads+writes)
- App-local 'Blocked relays' (excluded from every read/write)
- src/lib/relays.ts: resolveRelays()/useRelays() + URL normalisation
- NostrProvider req/event routers go through resolveRelays (blocklist applied)
- RelayListManager: Local/Blocked editors; NIP-65 save now preserves the new
lists instead of overwriting relayMetadata
Deliberately omitted (documented in AGENTS.md): KeyPackage (10051, no MLS
consumer) and proxy/broadcast/private-home (no such discovery model here).
Follow-up noted: a few hooks still read config.relayMetadata.relays directly.
New '/mints' page: browse Community MintRadar mints with health (online/latency/
degraded/archived), NUT count, terms, and add-to-wallet on native.
MintRadar has no CORS, so it's fetched same-origin at /api/mints/*:
- prod: Netlify proxy redirect in public/_redirects (+ 10m cache in _headers)
- dev: Vite server.proxy in vite.config.ts
This also hides users' IPs from MintRadar. Data is unofficial/rate-limited;
the page degrades to a friendly error state. Nav entry + smoke route added.
Add a step-by-step Android verification checklist to ESCROW.md (preconditions,
advertise key -> buyer escrow -> seller redeem -> refund -> guard rails) and
point to it from the Status section and AGENTS.md. Escrow stays unverified until
every box passes on a device with tiny amounts.
Completes the escrow safety loop: a buyer whose escrow token is past its
locktime can reclaim it from the listing card ('Refund escrow'), which redeems
the P2PK token with their refund key. Native-only, same as the rest of escrow.
Completes the escrow feature on top of the tested core (src/lib/cashuEscrow.ts):
- useCashuEscrow: native-only hook wiring createEscrow (cashu-ts mint + NWC/WebLN
funding), redeemEscrow, escrow-key derivation (coco keyring) and profile publish
- EscrowKeyCard (Wallet): advertise the kind-0 'cashu_escrow' pubkey
- EscrowRequestDialog (listing card): buyer locks funds (P2PK to seller, refundable
after N days) and sends the 31872 carrying the cashu_token
- EscrowPanel + 'Redeem escrow' in the DM request banner (seller side); the panel
is offline/parse-based so it renders on web too
- Buyer 'Escrow & request' supersedes the plain request when available
EXPERIMENTAL: mint-touching paths are unverified on a device (native-only wallet);
use small amounts first. Gate: 101 tests, 0 errors.
Real, oracle-free escrow built on Cashu P2PK with a locktime + refund key:
buyer locks funds to the seller; seller redeems on delivery; the buyer can
reclaim after the deadline. Locked (not plain) tokens are what make the
cashu_token on a public 31872 safe.
- src/lib/cashuEscrow.ts: pubkey normalisation (x-only -> compressed), P2PK
policy builder, offline token inspection (amount/mint/locktime/refund), and
an injectable createEscrowToken mint flow (quote -> pay -> mintProofs{p2pk})
- 15 unit tests incl. the mint orchestration against a fake wallet
- Protocol: advertise a 'cashu_escrow' pubkey in kind 0 (NIP.md); token rides
the existing 31872 cashu_token tag
- ESCROW.md: design, threat model, status, limitations
- AGENTS.md: project map + docs index updated
Wallet UI (cashu-ts + payment rail + profile publish) is native-only and still
needs on-device verification.
- Add a 'this file is the ONLY persistent memory' directive: record non-obvious
learnings in the same session, fix stale entries immediately, date claims
- Add a 'Project map' (HashRouter, custom kinds, marketplace/wallet/DM/lazy
routes, npm test baselines, untracked-junk list)
- Fix stale facts: BrowserRouter -> HashRouter; /src/pages + /src/lib were
understated; describe the smoke test's hash routes + crash-screen detection
- Strengthen System Prompt Management to treat AGENTS.md as long-term memory
Marketplace:
- Buyer can 'Ask seller to confirm' a sold listing -> publishes a 31872
Attestation Request (d/a/p), which the seller fulfils in one tap
- Seller sees incoming requests in the DM thread ('Confirm this sale?') and the
fulfilling 31871 carries the spec's 'request' tag
- useTradeAttestationRequest / useIncomingTradeRequests / useListingTradeRequest
- NIP.md documents 31872 and states escrow is intentionally NOT implemented
(coco-cashu has no P2PK/HTLC locking, wallet is native-only, and release-on-
attestation needs a witness) so nothing looks trustless that isn't
Hardening:
- scripts/smoke-test.mjs navigated to /settings etc. WITHOUT a hash, so with
HashRouter it only ever loaded the homepage and reported PASS. It now puts the
route in the hash, covers settings/stash/collections/stats/marketplace, and
fails on the 'This page slammed' error boundary (not just boot failure)
Symptom: in the long-running dev server some lazy routes (/settings, /stash,
/collections, /stats) hit the route error boundary ('This page slammed') while
others worked. Console: 'Failed to fetch dynamically imported module'; network:
504 for node_modules/.vite/deps/*.js. Vite's dep-optimizer cache had gone
missing after the server re-optimized on a lockfile change, leaving stale ?v=
hashes.
- Add lazyWithRetry: on a chunk-load failure, retry once, then do a single
full reload per session (avoids reload loops); clears the flag on success
- Use it for all 53 lazy routes in AppRouter
- Document the dev dep-cache gotcha + fix in AGENTS.md
- Reputation now exposes per-counterparty verified counts; the profile card
trust-weights each confirmed trade by the counterparty's web-of-trust score
(fresh-account rings can't inflate it) and labels Highly trusted / Building /
New trader
- usePendingTradeConfirmations finds sales a seller recorded that name you but
you haven't mirrored; the 1:1 DM thread shows a 'Did you complete this trade?'
banner with one-tap Confirm
Built on the Attestations NIP (31871); a listing is the Assertion Event:
- Seller 'Mark as sold' now opens a dialog (buyer npub + self-reported method +
optional amount) that marks the listing sold AND publishes a trade attestation
- Buyer sees 'Confirm this trade' on the listing and publishes the mirror
attestation -> mutual pair = a verified trade
- useTradeReputation aggregates sales/purchases/verified + counterparties
- Profile 'Trades' card; seller 'N verified trades' badge on listing cards
- NIP.md documents kind 31871 usage + our p/method/amount extensions
Payment method is self-reported metadata, never proof of payment.
The Wallet page was Cashu-only and said 'App only' on web, so anyone with an
NWC wallet connected saw nothing. Adds:
- useNWC.getBalance() (millisats -> sats) and makeInvoice() via NWCClient
- NwcWalletCard: live balance + refresh, Receive (create invoice, QR, copy),
Send (paste invoice, decoded amount shown, pay via the connected wallet)
Renders only when an NWC connection is active.
The people picker is now multi-select: tap to toggle (checkmark), then
'Start group (n)' opens the encrypted group thread, or 'Message' for one person.
Selecting one person still behaves exactly as before.
Uses the existing gift-wrap primitive (kind 14 rumor, one p-tag per recipient):
- useGroupConversations: lists multi-recipient threads, bucketed by the sorted
participant key; the inbox shows a Groups section
- GroupConversationPage (/messages/group/:participants): encrypted group thread
with sender names, send + error feedback
- smoke test now boots the new route
Group creation UI (multi-select in NewMessageDialog) is the next step. For
stronger guarantees (forward secrecy, rosters) Marmot/MLS or Cordn would be the
protocol to adopt later; noted in the hook.
npm update within existing ranges. @noble/* and @nostrify/types were held back
by peer ranges and stay put. Gate + boot test green; npx cap now 8.5.2 (a future
android:build will sync the native project to match).
- BackupReminder, CustomizeNavSheet, SetStatusDialog and LoginDialog are only
needed after the user acts -> React.lazy (Layout chunk 511 KB -> 486 KB)
- The Cashu balance widget now only mounts when a wallet likely exists (web:
localStorage hint; native: always, since the seed is in the KeyStore), so
wallet-less visitors never download the 245 KB Cashu chunk
Data-driven pass (adds rollup-plugin-visualizer behind ANALYZE=1):
- MapPage was statically imported by the router, dragging the map route AND the
IndexedDB offline-cache layer into the entry -> now lazy()
- NewsletterOverlay imported NEWSLETTER_ISSUES from the whole NewsletterPage
module -> types + data moved to lib/newsletterIssues.ts
- SasquatchGuide (assistant overlay) lazy-loaded in Layout
- Cashu stack is now a separate chunk (useCashu), off the critical path
Net: entry down ~61%; first paint no longer parses the map, the newsletter
page, the assistant or the wallet stack.
- Layout (app shell) imported useCashu, pulling the Cashu stack into the entry
chunk every visitor downloads. The three balance widgets now live in
components/wallet/CashuBalance.tsx and load via React.lazy.
- useNWC statically imported @getalby/sdk; it is now a type-only import plus a
dynamic import at call sites, so the SDK only loads when NWC is used.
Entry chunk: 1879 KB -> 1835 KB.
PageHeader now carries the app's neon glow (accent-tinted text-shadow), so the
unified header keeps the brand look rather than flattening it. Four more plain
headers migrate (title + accent + optional subtitle/actions).
- New shared PageHeader + PageContainer (width tokens: narrow/list/detail/wide)
to end the max-w drift between sections; piloted on ContactsPage
- Profile: the identity/contact sidebar now comes first on mobile (order-first /
lg:order-none) instead of being buried under every content section
showAwardDialog was never set true, so the dialog could never open; the inline
AwardBadgeInline form is the live flow. Removes ~142 lines plus its now-unused
Dialog imports, state and helper.
The calendar always initialised to the current month, so the Past tab (all
events in the past) opened on an empty month. It now anchors to the newest
past event, and re-anchors when the tab changes.
- Profile showed the live status indicator twice (avatar + name row) -> once
- Stash/Wallet informational text bumped from gray-600/700 to gray-400/500 for
readable contrast on the near-black background
- Map view now shows a 'No spots match / Clear filters' overlay when the active
filters hide everything (it previously looked blank/unloaded)
- 'List Gear' passes returnTo=/marketplace, so the back link and the post-publish
redirect return to Marketplace instead of dumping users on the map
The '$40k/BTC' era confirm-payment dialog was unreachable after the Pay button
was removed — deleted it, its handler and state (and the now-unused useCashu
import). In its place, listings show '≈ N sats' under the price using the live
rate, so buyers still see the sat amount.
- Bounty leaderboards no longer slice to top 50 before computing the current
user's rank (anyone outside the top 50 never saw their position), and each row
no longer prints the count twice
- Lab craft tabs: 4-up grid (11 tabs were laid out 5-across, leaving a lone
'Judges' orphan row); stale comment corrected
- Contacts search placeholder now says it filters by npub (it never matched the
displayed names)
- ShopDetailPage gallery reuses ImageLightbox: photos are now zoomable and the
whole set is reachable (it showed at most 5 and clicks did nothing)
- FAQPage gains static quick answers so the page is useful even if The
Sasquatch guide fails to open (it previously had zero intrinsic content)
- SpotPicker showed 'No spots found' during the initial load (also affected
CreateListing); now shows a loading state first
- Events: switching to the calendar view clears the date-window filter, which
only has chips in list view — it was silently hiding calendar events
- EmptyState: render <Link> as the button (a <button> inside <Link> was invalid
and double-focusable); AboutPage <a href> -> <Link> (no full reload)
- aria-label on icon-only controls: settings Back, conversation Refresh/Send,
crew chat Send
- Crew invite Accept/Decline raised to 44px touch targets
- Add Marketplace to the More menu (it had no primary-nav entry)
- CLINK: explain the copy/QR fallback when no CLINK-compatible wallet handles
the deep link, so 'Open in CLINK wallet' is not a silent no-op
- Sats prices used a hardcoded $1 = 2500 sats (i.e. $40k/BTC), so buyers were
quoted wrong amounts; now uses the live useUsdToSats rate, handles sats/BTC
directly, and shows no estimate for unsupported fiat
- Removed the 'Pay with Cashu' button, which only ever showed a 'coming soon'
toast — a dead affordance. Buyers use Contact Seller.
- geolocation: cap the overall deadline at 15s (was 25s) and shorten the native
plugin attempt to 8s, so a failed fix no longer hangs ~25-40s
- MapPage: only warm the location cache when permission is ALREADY granted, so
opening /map no longer fires the browser's 'Allow location?' prompt
- MapPage: add a desktop Near Me control (it was mobile-only)
- MapPage: on native, a denial is now retryable instead of permanently opening
the browser-only help sheet
Adds a shared ErrorState/PageError with a retry action. Detail pages (spot,
shop, collection, coach, crew, event) now show an honest failure + Try again
instead of '404 Not Found' — a relay hiccup previously implied the content was
deleted. The zap/bounty leaderboards surface errors instead of 'no content yet',
and the DM inbox gains a retry button.