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:
- Mints from Contacts — Fetches kind 10019
CashuMintListevents from people the user follows, ranks mints by how many contacts use them - Mint Recommendations — Fetches kind 38000
EcashMintRecommendationevents, 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:
- Fetch kind 3 contact list for current user → extract
ptags → list of followed pubkeys - Fetch kind 10019 events for those pubkeys → extract mint URLs
- 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:
- Fetch kind 38000 events with
#kfilter for38002(Cashu mint announcements) - Extract mint URLs from
utags - 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.