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
808 lines
26 KiB
JavaScript
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;
|
|
}
|