Files
client/plans/cashu-mint-discovery.md
2026-04-17 16:52:51 -04:00

12 KiB

Cashu Mint Discovery — Sidebar Mint Lists from Contacts & Recommendations

Overview

Add two mint discovery sections to the cashu.html sidebar that help users find mints:

  1. Mints from Contacts — Fetches kind 10019 CashuMintList events from people the user follows, ranks mints by how many contacts use them
  2. Mint Recommendations — Fetches kind 38000 EcashMintRecommendation events, shows community-recommended mints with their URLs

Each mint row has a [+ Add] button that adds the mint to the wallet (or to the setup textarea if no wallet exists yet).

Data Sources

Kind 10019 — CashuMintList

Published by every NIP-60 wallet user. Contains ["mint", "https://..."] tags. This is a replaceable event — one per user. The NDKCashuMintList class has a .mints getter that extracts URLs.

Query flow:

  1. Fetch kind 3 contact list for current user → extract p tags → list of followed pubkeys
  2. Fetch kind 10019 events for those pubkeys → extract mint URLs
  3. Count how many contacts use each mint → rank by popularity

Kind 38000 — EcashMintRecommendation

Explicit mint recommendations. Contains ["u", "https://..."] tags for mint URLs and ["k", "38002"] tag to filter for Cashu mints specifically. The NDKMintRecommendation class has a .urls getter.

Query flow:

  1. Fetch kind 38000 events with #k filter for 38002 (Cashu mint announcements)
  2. Extract mint URLs from u tags
  3. Group by URL, count recommendations

Sidebar Layout

┌─────────────────────────────────┐
│  [SideNav Header]               │
├─────────────────────────────────┤
│                                 │
│  Mints from Contacts            │
│  ─────────────────────          │
│  Searching contacts...          │
│                                 │
│  (after loading:)               │
│  mint.minibits.cash        5 ⊕  │
│  mint.coinos.io            3 ⊕  │
│  stablenut.umint.cash      2 ⊕  │
│  21mint.me                 1 ⊕  │
│                                 │
│  ─────────────────────          │
│  Mint Recommendations           │
│  ─────────────────────          │
│  Searching...                   │
│                                 │
│  (after loading:)               │
│  mint.minibits.cash        4 ⊕  │
│  mint.lnbits.com           2 ⊕  │
│  testnut.cashu.space       1 ⊕  │
│                                 │
├─────────────────────────────────┤
│  リレー                          │
│  relay status...                │
├─────────────────────────────────┤
│  v0.2.78  [theme] [logout]      │
└─────────────────────────────────┘

The number next to each mint shows how many contacts use it (section 1) or how many recommendations it has (section 2). The ⊕ button adds the mint.

Implementation Plan

Step 1: Add discoverMintsFromContacts to cashu-wallet.mjs

New exported function in cashu-wallet.mjs that uses the controller's existing NDK instance:

async function discoverMintsFromContacts(pubkey) {
  // 1. Fetch kind 3 contact list
  const contactEvent = await ndk.fetchEvent({
    kinds: [NDKKind.Contacts],  // kind 3
    authors: [pubkey]
  });

  if (!contactEvent) return [];

  // 2. Extract followed pubkeys from p tags
  const followedPubkeys = contactEvent.tags
    .filter(t => t[0] === 'p' && t[1])
    .map(t => t[1]);

  if (followedPubkeys.length === 0) return [];

  // 3. Fetch kind 10019 mint lists from followed users
  //    NDK fetchEvents supports arrays of authors
  //    Batch in chunks of ~100 to avoid filter size limits
  const mintCounts = new Map(); // mintUrl -> count

  const BATCH_SIZE = 100;
  for (let i = 0; i < followedPubkeys.length; i += BATCH_SIZE) {
    const batch = followedPubkeys.slice(i, i + BATCH_SIZE);
    const mintListEvents = await ndk.fetchEvents({
      kinds: [NDKKind.CashuMintList],  // 10019
      authors: batch
    });

    for (const event of mintListEvents) {
      const mintTags = event.tags.filter(t => t[0] === 'mint');
      for (const tag of mintTags) {
        const url = normalizeMintUrl(tag[1]);
        if (url) {
          mintCounts.set(url, (mintCounts.get(url) || 0) + 1);
        }
      }
    }
  }

  // 4. Return sorted by count descending
  return Array.from(mintCounts.entries())
    .map(([url, count]) => ({ url, count }))
    .sort((a, b) => b.count - a.count);
}

Step 2: Add discoverMintRecommendations to cashu-wallet.mjs

async function discoverMintRecommendations() {
  // Fetch kind 38000 events filtered for Cashu mint announcements
  const recEvents = await ndk.fetchEvents({
    kinds: [NDKKind.EcashMintRecommendation],  // 38000
    '#k': ['38002']  // filter for CashuMintAnnouncement kind
  });

  const mintCounts = new Map(); // mintUrl -> count

  for (const event of recEvents) {
    const urlTags = event.tags.filter(t => t[0] === 'u');
    for (const tag of urlTags) {
      const url = normalizeMintUrl(tag[1]);
      if (url) {
        mintCounts.set(url, (mintCounts.get(url) || 0) + 1);
      }
    }
  }

  return Array.from(mintCounts.entries())
    .map(([url, count]) => ({ url, count }))
    .sort((a, b) => b.count - a.count);
}

Step 3: Expose both functions from the controller

Add both to the returned object from createCashuWalletController:

return {
  // ...existing exports...
  discoverMintsFromContacts: () => discoverMintsFromContacts(pubkey),
  discoverMintRecommendations,
};

Step 4: Add sidebar HTML structure

In cashu.html, add two container divs inside #divSideNavBody:

<div id="divSideNavBody">
  <div id="divFiles"></div>
  <div id="divMintDiscovery">
    <div id="divContactMints">
      <div class="sidenavSectionTitle">Mints from Contacts</div>
      <div id="divContactMintsList">Searching contacts…</div>
    </div>
    <div id="divRecommendedMints">
      <div class="sidenavSectionTitle">Mint Recommendations</div>
      <div id="divRecommendedMintsList">Searching…</div>
    </div>
  </div>
</div>

Step 5: Add sidebar CSS

Page-specific styles in cashu.html <style> block:

#divMintDiscovery {
  padding: 10px;
  display: flex;
  flex-direction: column;
  gap: 15px;
}

.sidenavSectionTitle {
  font-size: 90%;
  font-weight: bold;
  color: var(--primary-color);
  border-bottom: 1px solid var(--muted-color);
  padding-bottom: 5px;
  margin-bottom: 5px;
}

.mintDiscoveryRow {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 5px;
  font-size: 75%;
  padding: 3px 0;
}

.mintDiscoveryUrl {
  flex: 1;
  word-break: break-all;
  color: var(--primary-color);
}

.mintDiscoveryCount {
  color: var(--muted-color);
  min-width: 20px;
  text-align: right;
}

.mintDiscoveryAdd {
  cursor: pointer;
  color: var(--accent-color);
  font-size: 110%;
  padding: 0 4px;
  border: 1px solid var(--muted-color);
  border-radius: 3px;
  background: none;
  line-height: 1;
}

.mintDiscoveryAdd:hover {
  background: var(--accent-color);
  color: var(--secondary-color);
}

Step 6: Populate sidebar on openNav and on page load

In the openNav() function, replace the static text with the mint discovery sections. Trigger the async fetch:

function openNav() {
  // ...existing hamburger logic...

  // Render mint discovery sections (populated async)
  renderMintDiscoverySidebar();

  // ...existing logout/theme hamburger logic...
}

async function renderMintDiscoverySidebar() {
  // Show loading state
  const divContactMintsList = document.getElementById('divContactMintsList');
  const divRecommendedMintsList = document.getElementById('divRecommendedMintsList');

  if (divContactMintsList) divContactMintsList.textContent = 'Searching contacts…';
  if (divRecommendedMintsList) divRecommendedMintsList.textContent = 'Searching…';

  // Fetch both in parallel
  try {
    const [contactMints, recommendedMints] = await Promise.all([
      walletController.discoverMintsFromContacts(),
      walletController.discoverMintRecommendations()
    ]);

    renderMintList(divContactMintsList, contactMints, 'No mints found from contacts');
    renderMintList(divRecommendedMintsList, recommendedMints, 'No recommendations found');
  } catch (error) {
    console.error('[cashu.html] Mint discovery failed:', error);
    if (divContactMintsList) divContactMintsList.textContent = 'Discovery failed';
    if (divRecommendedMintsList) divRecommendedMintsList.textContent = 'Discovery failed';
  }
}

function renderMintList(container, mints, emptyMessage) {
  if (!container) return;

  if (!mints || mints.length === 0) {
    container.textContent = emptyMessage;
    return;
  }

  container.innerHTML = mints.map(({ url, count }) => `
    <div class="mintDiscoveryRow">
      <div class="mintDiscoveryUrl">${url}</div>
      <div class="mintDiscoveryCount">${count}</div>
      <button class="mintDiscoveryAdd" data-mint-url="${url}" title="Add mint">⊕</button>
    </div>
  `).join('');

  // Bind add buttons
  container.querySelectorAll('.mintDiscoveryAdd').forEach(btn => {
    btn.addEventListener('click', () => handleAddDiscoveredMint(btn.dataset.mintUrl));
  });
}

async function handleAddDiscoveredMint(mintUrl) {
  if (walletController.hasWallet()) {
    // Add to existing wallet
    try {
      setStatus('Adding mint…');
      await walletController.addMint(mintUrl);
      await refreshAllWalletViews();
      setStatus('Mint added');
    } catch (error) {
      setStatus(error.message || 'Failed to add mint', true);
    }
  } else {
    // Append to setup textarea
    const current = taSetupMints.value.trim();
    const normalized = mintUrl.trim();
    if (!current.split('\n').map(l => l.trim()).includes(normalized)) {
      taSetupMints.value = current ? current + '\n' + normalized : normalized;
    }
    setStatus('Mint added to setup list');
  }
}

Step 7: Also trigger discovery on page load (background)

Start the mint discovery fetch after wallet initialization completes, so the data is ready when the user opens the sidebar:

// After wallet init in main()
// Pre-fetch mint discovery data in background
walletController.discoverMintsFromContacts().catch(() => {});
walletController.discoverMintRecommendations().catch(() => {});

Optionally cache the results in the controller so repeated sidebar opens don't re-fetch.

Files to Modify

File Changes
www/js/cashu-wallet.mjs Add discoverMintsFromContacts() and discoverMintRecommendations() functions, expose from controller
www/cashu.html — HTML Add mint discovery container divs inside #divSideNavBody
www/cashu.html — CSS Add styles for .sidenavSectionTitle, .mintDiscoveryRow, .mintDiscoveryUrl, .mintDiscoveryCount, .mintDiscoveryAdd
www/cashu.html — JS Add renderMintDiscoverySidebar(), renderMintList(), handleAddDiscoveredMint(), update openNav()

Technical Notes

  • The cashu-wallet controller already creates its own NDK instance at line 52 connected to the user's relays, so it can fetch kind 3 and kind 10019 events directly without going through the worker.
  • Kind 3 is the user's contact list — ["p", "<hex-pubkey>"] tags list followed users.
  • Kind 10019 is CashuMintList["mint", "https://..."] tags list mint URLs. One per user (replaceable).
  • Kind 38000 is EcashMintRecommendation["u", "https://..."] tags for mint URLs, ["k", "38002"] to filter for Cashu.
  • Batching the kind 10019 fetch in chunks of 100 pubkeys avoids relay filter size limits.
  • The ⊕ button behavior is context-aware: adds to wallet if one exists, or appends to setup textarea if in setup mode.