Files
client/www/js/zaps.mjs
Laan Tungir a42edcf6d6 Feed upgrade Phases 3-5: zap capability indicators, rail selector, balance checks
Phase 3 — Zap Capability Indicators:
- Added resolveZapCapabilities() to zaps.mjs with 5-min TTL cache
- Checks lud16 (from profile cache) for Lightning capability
- Checks kind 10019 (via walletFetchMintList) for nutzap capability
- Computes shared mints between sender and recipient
- Added capability badges (🥜/) to zap button in post-interactions.mjs
- Badges use var(--accent-color) for available, var(--muted-color) for unavailable
- Added proactive kind 10019 batch fetch for followed users in ndk-worker.js
- New IDB store ndk-nutzap-mintlists for persistent caching (6h TTL)
- Non-blocking: badges appear after async resolution

Phase 4 — Zap Rail Selector:
- Updated promptZapDetails with rail toggle (🥜 Nutzap /  Lightning)
- Shows Cashu balance and insufficient balance warning
- Shows shared mint info when nutzap selected
- Default rail: nutzap for <1000 sats, Lightning for >=1000 sats
- Updated handleZapClick to resolve capabilities and respect user's rail choice
- Added zap rail toggle CSS to client.css (all CSS variables)
- Wired walletGetBalance through post-interactions2.mjs and post-feed.html

Phase 5 — Balance Check and Error Handling:
- Pre-flight balance check before sending (hard stop if insufficient)
- Barely-sufficient balance confirmation dialog
- Friendly error messages for melt/swap/timeout/insufficient failures
- Post-zap balance refresh via ndkWalletBalance event

Review fix: threaded walletGetMints through post-interactions2.mjs and post-feed.html
so resolveZapCapabilities can compute shared mints correctly
2026-06-25 20:48:38 -04:00

808 lines
26 KiB
JavaScript

const DEFAULT_FALLBACK_RELAY_HINTS = ['wss://relay.damus.io'];
let activeZapModalEl = null;
export function normalizeLud16(lud16) {
const value = String(lud16 || '').trim();
if (!value || !value.includes('@')) return '';
const [name, domain] = value.split('@');
if (!name || !domain) return '';
return `${name}@${domain}`;
}
export async function resolveZapSpecForPubkey(pubkey, options = {}) {
const { fetchProfile, logPrefix = '[zaps]' } = options;
if (typeof fetchProfile !== 'function') {
throw new Error('resolveZapSpecForPubkey requires fetchProfile(pubkey)');
}
console.log(`${logPrefix} resolveZapSpecForPubkey:start`, {
recipient: String(pubkey || '').slice(0, 8) + '…'
});
const profile = await fetchProfile(pubkey);
const lud16 = normalizeLud16(profile?.lud16);
if (!lud16) {
throw new Error('Recipient has no lud16 lightning address');
}
const [name, domain] = lud16.split('@');
const lnurl = new URL(`/.well-known/lnurlp/${encodeURIComponent(name)}`, `https://${domain}`).toString();
console.log(`${logPrefix} resolveZapSpecForPubkey:lnurl`, { lud16, lnurl });
const response = await fetch(lnurl, {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (!response.ok) {
throw new Error(`LNURL lookup failed (${response.status})`);
}
const body = await response.json();
if (body?.status === 'ERROR') {
throw new Error(body?.reason || 'LNURL server returned an error');
}
if (!body?.callback) {
throw new Error('LNURL pay response missing callback');
}
if (!body?.allowsNostr || !body?.nostrPubkey) {
throw new Error('Recipient lightning address does not support zaps');
}
const spec = {
callback: body.callback,
minSendable: Number(body.minSendable) || 0,
maxSendable: Number(body.maxSendable) || Number.MAX_SAFE_INTEGER,
nostrPubkey: body.nostrPubkey,
lnurl
};
console.log(`${logPrefix} resolveZapSpecForPubkey:ok`, {
callbackHost: (() => {
try { return new URL(spec.callback).host; } catch (_) { return '(invalid)'; }
})(),
minSendable: spec.minSendable,
maxSendable: spec.maxSendable
});
return spec;
}
export async function resolveNutzapSpecForPubkey(pubkey, options = {}) {
const { fetchMintList, logPrefix = '[zaps]' } = options;
if (typeof fetchMintList !== 'function') {
throw new Error('resolveNutzapSpecForPubkey requires fetchMintList(pubkey)');
}
const recipient = String(pubkey || '').trim();
if (!recipient) {
throw new Error('Recipient pubkey is required');
}
console.log(`${logPrefix} resolveNutzapSpecForPubkey:start`, {
recipient: recipient.slice(0, 8) + '…'
});
const mintList = await fetchMintList(recipient);
const mints = Array.isArray(mintList?.mints)
? mintList.mints.map((m) => String(m || '').trim().replace(/\/+$/, '')).filter(Boolean)
: [];
const relays = Array.isArray(mintList?.relays)
? mintList.relays.map((r) => String(r || '').trim()).filter((r) => /^wss?:\/\//i.test(r))
: [];
const p2pk = String(mintList?.p2pk || '').trim() || null;
if (!Boolean(mintList?.hasMintList) || mints.length === 0) {
throw new Error('Recipient has no kind 10019 mint list for nutzaps');
}
const spec = {
mints: Array.from(new Set(mints)),
relays: Array.from(new Set(relays)),
p2pk
};
console.log(`${logPrefix} resolveNutzapSpecForPubkey:ok`, {
mints: spec.mints.length,
relays: spec.relays.length,
hasP2pk: Boolean(spec.p2pk)
});
return spec;
}
export async function getZapRelayHints(options = {}) {
const {
getRelayData,
fallbackRelays = DEFAULT_FALLBACK_RELAY_HINTS
} = options;
if (typeof getRelayData !== 'function') {
return [...fallbackRelays];
}
try {
const relays = await getRelayData();
const list = Array.isArray(relays) ? relays : [];
const connected = list.filter((relay) => String(relay?.status || '').toLowerCase() === 'connected');
const source = connected.length > 0 ? connected : list;
const relayUrls = source
.map((relay) => String(relay?.url || '').trim())
.filter((url) => /^wss?:\/\//i.test(url))
.slice(0, 4);
if (relayUrls.length > 0) return relayUrls;
} catch (_error) {}
return [...fallbackRelays];
}
export async function createSignedZapRequest(options = {}) {
const {
eventId = '',
recipientPubkey,
amountSats,
comment = '',
zapSpec,
relayHints,
getRelayData,
logPrefix = '[zaps]'
} = options;
if (!window?.nostr?.signEvent) {
throw new Error('Signer unavailable (window.nostr.signEvent missing)');
}
if (!recipientPubkey) {
throw new Error('Recipient pubkey is required');
}
const amountMsats = Math.floor(Number(amountSats) * 1000);
const resolvedRelayHints = Array.isArray(relayHints) && relayHints.length > 0
? relayHints
: await getZapRelayHints({ getRelayData });
const tags = [
['p', recipientPubkey],
['amount', String(amountMsats)],
['relays', ...resolvedRelayHints],
['lnurl', zapSpec?.lnurl || '']
];
if (eventId) {
tags.push(['e', eventId]);
}
const unsignedZapRequest = {
kind: 9734,
created_at: Math.floor(Date.now() / 1000),
content: String(comment || ''),
tags
};
console.log(`${logPrefix} createSignedZapRequest:signing`, {
eventId: String(eventId || '').slice(0, 8) + '…',
recipient: String(recipientPubkey || '').slice(0, 8) + '…',
amountSats,
relayHints: resolvedRelayHints
});
const signed = await window.nostr.signEvent(unsignedZapRequest);
console.log(`${logPrefix} createSignedZapRequest:ok`, {
id: String(signed?.id || '').slice(0, 12) + '…'
});
return signed;
}
export async function fetchZapInvoice(options = {}) {
const {
zapSpec,
amountSats,
signedZapRequest,
logPrefix = '[zaps]'
} = options;
const amountMsats = Math.floor(Number(amountSats) * 1000);
const callbackUrl = new URL(zapSpec.callback);
callbackUrl.searchParams.set('amount', String(amountMsats));
callbackUrl.searchParams.set('nostr', JSON.stringify(signedZapRequest));
callbackUrl.searchParams.set('lnurl', zapSpec.lnurl);
console.log(`${logPrefix} fetchZapInvoice:start`, {
callbackHost: callbackUrl.host,
amountMsats
});
const response = await fetch(callbackUrl.toString(), {
method: 'GET',
headers: { Accept: 'application/json' }
});
if (!response.ok) {
throw new Error(`Invoice fetch failed (${response.status})`);
}
const body = await response.json();
if (body?.status === 'ERROR') {
throw new Error(body?.reason || 'LNURL callback returned an error');
}
const invoice = String(body?.pr || '').trim();
if (!invoice) {
throw new Error('LNURL callback did not return an invoice');
}
console.log(`${logPrefix} fetchZapInvoice:ok`, {
invoicePrefix: invoice.slice(0, 20) + '…'
});
return invoice;
}
function closeActiveZapModal(result, resolver) {
if (activeZapModalEl?.parentNode) {
activeZapModalEl.parentNode.removeChild(activeZapModalEl);
}
activeZapModalEl = null;
resolver(result);
}
/**
* Escape a string for safe insertion into HTML text/attribute content.
* @param {string} value
* @returns {string}
*/
const HTML_ESCAPE_RE = /[&<>"']/g;
function escapeHtml(value) {
const amp = '&' + 'amp;';
const lt = '&' + 'lt;';
const gt = '&' + 'gt;';
const quot = '&' + 'quot;';
const apos = '&' + '#39;';
const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos };
return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]);
}
/**
* Choose the default zap rail based on availability and amount.
* - nutzap available and amount < 1000 sats → nutzap
* - nutzap available and amount >= 1000 sats → lightning
* - nutzap not available → lightning (if available)
* - neither available → null
* @param {Object} railOptions - { canLightning, canNutzap }
* @param {number} amountSats
* @returns {'nutzap'|'lightning'|null}
*/
function chooseDefaultRail(railOptions, amountSats) {
const { canLightning = false, canNutzap = false } = railOptions || {};
if (canNutzap && Number(amountSats) < 1000) return 'nutzap';
if (canLightning) return 'lightning';
if (canNutzap) return 'nutzap';
return null;
}
export function promptZapDetails(options = {}) {
if (activeZapModalEl) {
return Promise.resolve(null);
}
const {
defaultAmountSats = 21,
defaultComment = '',
defaultShouldZap = true,
onSaveDefault = null,
railOptions = null,
balanceSats = null
} = options;
// Normalize rail options.
const canLightning = Boolean(railOptions?.canLightning);
const canNutzap = Boolean(railOptions?.canNutzap);
const sharedMints = Array.isArray(railOptions?.sharedMints)
? railOptions.sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
const hasAnyRail = canLightning || canNutzap;
return new Promise((resolve) => {
const initialAmount = Number(defaultAmountSats);
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
? Math.floor(initialAmount)
: 21;
const normalizedDefaultComment = String(defaultComment || '').trim();
// Resolve the initial rail selection.
let selectedRail = hasAnyRail
? chooseDefaultRail({ canLightning, canNutzap }, normalizedDefaultAmount)
: null;
const showRailToggle = canLightning && canNutzap;
const primarySharedMint = sharedMints[0] || '';
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
: '';
const noRailMessage = !hasAnyRail
? `<div class="zap-selector-error">Recipient cannot receive zaps</div>`
: '';
const railToggleHtml = showRailToggle
? `<div class="zap-rail-toggle" role="group" aria-label="Zap rail">
<button type="button" class="zap-rail-option" data-rail="nutzap">🥜 Nutzap</button>
<button type="button" class="zap-rail-option" data-rail="lightning">⚡ Lightning</button>
</div>`
: (hasAnyRail
? `<div class="zap-rail-single zap-rail-option active" data-rail="${selectedRail === 'nutzap' ? 'nutzap' : 'lightning'}">
${selectedRail === 'nutzap' ? '🥜 Nutzap' : '⚡ Lightning'}
</div>`
: '');
const sharedMintInfoHtml = (canNutzap && primarySharedMint)
? `<div class="zap-selector-mint-info" data-rail-info="nutzap">Will send via ${escapeHtml(primarySharedMint)}</div>`
: '';
const overlay = document.createElement('div');
overlay.className = 'zap-selector-overlay';
overlay.innerHTML = `
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
<div class="zap-selector-title">Send Zap</div>
${noRailMessage}
${balanceDisplay}
${railToggleHtml}
<div class="zap-selector-presets">
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
<button type="button" class="zap-preset" data-amount="500">⚡500</button>
<button type="button" class="zap-preset" data-amount="1000">⚡1K</button>
<button type="button" class="zap-preset" data-amount="5000">⚡5K</button>
</div>
<label class="zap-selector-label">
Amount (sats)
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
</label>
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
${sharedMintInfoHtml}
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${escapeHtml(normalizedDefaultComment)}</textarea>
</label>
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
Send zap
</label>
<div class="zap-selector-actions">
<button type="button" class="zap-cancel">Cancel</button>
<button type="button" class="zap-save-default">Save as default</button>
<button type="button" class="zap-send" ${hasAnyRail ? '' : 'disabled'}>Send</button>
</div>
</div>
`;
const amountEl = overlay.querySelector('.zap-selector-amount');
const commentEl = overlay.querySelector('.zap-selector-comment');
const sendBtn = overlay.querySelector('.zap-send');
const saveDefaultBtn = overlay.querySelector('.zap-save-default');
const cancelBtn = overlay.querySelector('.zap-cancel');
const enableZapEl = overlay.querySelector('.zap-selector-enable');
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
const railOptionButtons = Array.from(overlay.querySelectorAll('.zap-rail-option'));
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const mintInfoEl = overlay.querySelector('[data-rail-info="nutzap"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
const comment = String(commentEl?.value || '').trim();
const shouldZap = Boolean(enableZapEl?.checked);
return { amountSats, comment, shouldZap };
};
const updateRailSelection = (rail) => {
if (!rail || !hasAnyRail) return;
selectedRail = rail;
railOptionButtons.forEach((btn) => {
btn.classList.toggle('active', btn.dataset?.rail === rail);
});
// Show/hide shared mint info based on selected rail.
if (mintInfoEl) {
mintInfoEl.style.display = (rail === 'nutzap') ? '' : 'none';
}
};
const updateBalanceWarning = () => {
if (!warningEl) return;
const { amountSats } = parseCurrentValues();
const balance = Number(balanceSats);
const insufficient = Number.isFinite(balance)
&& balance >= 0
&& Number.isFinite(amountSats)
&& amountSats > balance;
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
warningEl.style.display = insufficient ? '' : 'none';
};
const selectPreset = (button) => {
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
if (amountEl && button?.dataset?.amount) {
amountEl.value = button.dataset.amount;
}
// Re-evaluate default rail when amount changes via preset.
if (showRailToggle) {
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, Number(button?.dataset?.amount || 0));
if (newDefault) updateRailSelection(newDefault);
}
updateBalanceWarning();
};
presetButtons.forEach((button) => {
button.addEventListener('click', () => selectPreset(button));
});
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
// Re-evaluate default rail as the user types.
if (showRailToggle) {
const { amountSats } = parseCurrentValues();
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, amountSats);
if (newDefault) updateRailSelection(newDefault);
}
updateBalanceWarning();
});
railOptionButtons.forEach((button) => {
button.addEventListener('click', () => {
const rail = button?.dataset?.rail;
if (rail === 'nutzap' || rail === 'lightning') {
updateRailSelection(rail);
}
});
});
saveDefaultBtn?.addEventListener('click', async () => {
const { amountSats, comment } = parseCurrentValues();
if (!Number.isFinite(amountSats) || amountSats <= 0) {
amountEl?.focus();
return;
}
if (typeof onSaveDefault === 'function') {
try {
await onSaveDefault({ amountSats, comment });
saveDefaultBtn.textContent = 'Saved';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
} catch (error) {
console.error('[zaps] Save as default failed:', error?.message || error, error);
saveDefaultBtn.textContent = 'Save failed';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
}
return;
}
console.warn('[zaps] Save as default unavailable: onSaveDefault callback not provided');
saveDefaultBtn.textContent = 'Unavailable';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
});
sendBtn?.addEventListener('click', () => {
if (!hasAnyRail) return;
const { amountSats, comment, shouldZap } = parseCurrentValues();
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
amountEl?.focus();
return;
}
closeActiveZapModal({ amountSats, comment, shouldZap, rail: selectedRail }, resolve);
});
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) closeActiveZapModal(null, resolve);
});
const onEscape = (event) => {
if (event.key !== 'Escape') return;
document.removeEventListener('keydown', onEscape);
closeActiveZapModal(null, resolve);
};
document.addEventListener('keydown', onEscape);
activeZapModalEl = overlay;
document.body.appendChild(overlay);
const defaultPreset = presetButtons.find((btn) => Number(btn.dataset?.amount || 0) === normalizedDefaultAmount);
if (defaultPreset) {
selectPreset(defaultPreset);
} else {
presetButtons.forEach((btn) => btn.classList.remove('active'));
}
// Initialize rail selection and dependent UI state.
updateRailSelection(selectedRail);
updateBalanceWarning();
amountEl?.focus();
});
}
export async function prepareZapInvoiceForEvent(options = {}) {
const {
eventId = '',
recipientPubkey,
amountSats,
comment = '',
fetchProfile,
getRelayData,
logPrefix = '[zaps]',
onProgress = null
} = options;
const emitProgress = (step, data = {}, level = 'info') => {
if (typeof onProgress !== 'function') return;
try {
onProgress({ step, level, ...data });
} catch (_error) {}
};
emitProgress('resolve-zap-spec:start', {
recipientPubkey: String(recipientPubkey || '').slice(0, 12)
});
const zapSpec = await resolveZapSpecForPubkey(recipientPubkey, { fetchProfile, logPrefix });
const amountMsats = Math.floor(Number(amountSats) * 1000);
emitProgress('resolve-zap-spec:ok', {
callback: zapSpec?.callback || '',
minSendable: zapSpec?.minSendable || 0,
maxSendable: zapSpec?.maxSendable || 0,
amountMsats
});
if (amountMsats < zapSpec.minSendable) {
emitProgress('amount-check:below-min', {
amountMsats,
minSendable: zapSpec.minSendable
}, 'error');
throw new Error(`Amount is below minimum (${Math.ceil(zapSpec.minSendable / 1000)} sats)`);
}
if (amountMsats > zapSpec.maxSendable) {
emitProgress('amount-check:above-max', {
amountMsats,
maxSendable: zapSpec.maxSendable
}, 'error');
throw new Error(`Amount exceeds maximum (${Math.floor(zapSpec.maxSendable / 1000)} sats)`);
}
emitProgress('create-zap-request:start', { amountSats, hasComment: Boolean(String(comment || '').trim()) });
const signedZapRequest = await createSignedZapRequest({
eventId,
recipientPubkey,
amountSats,
comment,
zapSpec,
getRelayData,
logPrefix
});
emitProgress('create-zap-request:ok', {
eventId: String(eventId || '').slice(0, 12),
requestId: String(signedZapRequest?.id || '').slice(0, 16)
});
emitProgress('fetch-invoice:start', {
callback: zapSpec?.callback || '',
amountMsats
});
const invoice = await fetchZapInvoice({
zapSpec,
amountSats,
signedZapRequest,
logPrefix
});
emitProgress('fetch-invoice:ok', {
invoicePrefix: String(invoice || '').slice(0, 24)
});
return {
invoice,
zapSpec,
signedZapRequest
};
}
export function extractZapAmount(event) {
const bolt11 = event?.tags?.find((t) => t[0] === 'bolt11')?.[1];
if (!bolt11) return 0;
try {
const sats = window.NostrTools?.nip57?.getSatoshisAmountFromBolt11?.(bolt11);
return Number.isFinite(sats) ? sats : 0;
} catch (_error) {
return 0;
}
}
export function extractZapComment(event) {
const description = event?.tags?.find((t) => t[0] === 'description')?.[1];
if (description) {
try {
const descObj = JSON.parse(description);
return descObj.content || '';
} catch (_e) {
return '';
}
}
return '';
}
// =============================================================================
// ZAP CAPABILITY RESOLUTION (Phase 3 — feed-upgrade)
// =============================================================================
//
// resolveZapCapabilities(pubkey) determines, at a glance, which zap rails are
// available for a given recipient pubkey:
// - canLightning: recipient has a lud16 Lightning address (NIP-57 via Cashu melt)
// - canNutzap: recipient has a kind 10019 mint list (NIP-61) AND at least
// one of those mints overlaps with the sender's wallet mints
// - sharedMints: the overlapping mint URLs (empty when no overlap)
// - nutzapMints: all mints declared on the recipient's kind 10019
// - lud16: the recipient's Lightning address (or null)
//
// Results are cached per-pubkey in an in-memory Map with a 5-minute TTL so
// repeated feed renders do not refetch. The cache is best-effort and safe to
// clear on page navigation (see clearZapCapabilitiesCache).
const ZAP_CAP_CACHE_TTL_MS = 5 * 60 * 1000;
const zapCapCache = new Map(); // pubkey -> { result, expiresAt }
/**
* Clear the in-memory zap-capability cache.
* Safe to call on page navigation; subsequent calls will refetch.
*/
export function clearZapCapabilitiesCache() {
zapCapCache.clear();
}
/**
* Normalize a mint URL for comparison (trim + strip trailing slashes).
* @param {string} url
* @returns {string}
*/
function normalizeMintForCompare(url) {
return String(url || '').trim().replace(/\/+$/, '').toLowerCase();
}
/**
* Resolve which zap rails are available for a given pubkey.
*
* @param {string} pubkey - The recipient's pubkey (hex)
* @param {Object} options
* @param {Function} [options.fetchProfile] - profile-cache fetcher (kind 0)
* @param {Function} [options.fetchMintList] - walletFetchMintList(pubkey) -> { hasMintList, mints, ... }
* @param {Function} [options.getSenderMints] - walletGetMints() -> { mints: [...] }
* @param {boolean} [options.useCache=true] - whether to consult the in-memory cache
* @param {string} [options.logPrefix='[zaps]']
* @returns {Promise<{canLightning:boolean, canNutzap:boolean, sharedMints:string[], nutzapMints:string[], lud16:string|null, hasMintList:boolean}>}
*/
export async function resolveZapCapabilities(pubkey, options = {}) {
const {
fetchProfile: fetchProfileFn,
fetchMintList,
getSenderMints,
useCache = true,
logPrefix = '[zaps]'
} = options;
const recipient = String(pubkey || '').trim();
if (!recipient) {
return {
canLightning: false,
canNutzap: false,
sharedMints: [],
nutzapMints: [],
lud16: null,
hasMintList: false
};
}
// --- Cache lookup ---------------------------------------------------------
const now = Date.now();
if (useCache) {
const cached = zapCapCache.get(recipient);
if (cached && cached.expiresAt > now) {
return cached.result;
}
}
// --- Lightning capability (lud16 from kind 0) -----------------------------
let lud16 = null;
let canLightning = false;
if (typeof fetchProfileFn === 'function') {
try {
const profile = await fetchProfileFn(recipient);
const normalized = normalizeLud16(profile?.lud16);
if (normalized) {
lud16 = normalized;
canLightning = true;
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: profile fetch failed`, error?.message || error);
}
}
// --- Nutzap capability (kind 10019 + shared mint overlap) -----------------
let nutzapMints = [];
let sharedMints = [];
let canNutzap = false;
let hasMintList = false;
if (typeof fetchMintList === 'function') {
try {
const mintList = await fetchMintList(recipient);
const rawMints = Array.isArray(mintList?.mints)
? mintList.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
nutzapMints = Array.from(new Set(rawMints));
hasMintList = Boolean(mintList?.hasMintList) && nutzapMints.length > 0;
if (hasMintList) {
// Determine the sender's wallet mints to compute overlap.
let senderMints = [];
if (typeof getSenderMints === 'function') {
try {
const senderResult = await getSenderMints();
senderMints = Array.isArray(senderResult?.mints)
? senderResult.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: sender mints fetch failed`, error?.message || error);
}
}
if (senderMints.length > 0) {
const senderSet = new Set(senderMints.map(normalizeMintForCompare));
sharedMints = nutzapMints.filter((m) => senderSet.has(normalizeMintForCompare(m)));
canNutzap = sharedMints.length > 0;
} else {
// No sender wallet mints available yet — cannot confirm a shared mint,
// so we do not claim canNutzap. The recipient still has a mint list,
// which the UI may render in a muted state.
canNutzap = false;
}
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: mint list fetch failed`, error?.message || error);
}
}
const result = {
canLightning,
canNutzap,
sharedMints,
nutzapMints,
lud16,
hasMintList
};
// --- Cache store ----------------------------------------------------------
zapCapCache.set(recipient, {
result,
expiresAt: Date.now() + ZAP_CAP_CACHE_TTL_MS
});
console.log(`${logPrefix} resolveZapCapabilities:ok`, {
recipient: recipient.slice(0, 8) + '…',
canLightning,
canNutzap,
hasMintList,
sharedMints: sharedMints.length,
nutzapMints: nutzapMints.length
});
return result;
}