Compare commits

..

10 Commits

Author SHA1 Message Date
Laan Tungir
c9a20e63b5 Keep broadcast result in footer until next publish overwrites it (remove 10s auto-clear) 2026-06-30 10:13:46 -04:00
Laan Tungir
79e38b8f79 Experiment: increase per-relay broadcast timeout to 60s to test if NDK can handle 600 relays with longer connection time 2026-06-30 10:11:08 -04:00
Laan Tungir
7cfd43b91b Set per-relay broadcast timeout to 5s, overall deadline to totalRelays*5s (min 30s). NDK runs relays in parallel so 600 relays finish in ~5s not 3000s 2026-06-30 10:06:37 -04:00
Laan Tungir
777ab312a5 Fix broadcast timeout: increase connection timeout to 15s for temporary relays, race publish against 45s overall deadline so one slow relay doesn't block 2026-06-30 10:02:00 -04:00
Laan Tungir
2763dd9d6a Add relay URL hover tooltip on post time: shows 'Published to N relays:' with URL list when hovering over the relay count 2026-06-30 09:37:36 -04:00
Laan Tungir
a20b28cd21 Fix relay count display: emit broadcastProgress done event for all publishes (not just broadcasts), add missing eventId/eventKind to broadcast done event 2026-06-30 09:33:08 -04:00
Laan Tungir
456f0e1f2e Add relay count to post time display: shows '21r - 1h' format on each post card, tracking successful relay count from broadcastProgress events 2026-06-30 09:28:38 -04:00
Laan Tungir
4abb4bf33c Add 10-sat fee-reserve buffer to handleWalletPayInvoice melt to fix 'not enough inputs provided for melt' errors 2026-06-30 09:19:30 -04:00
Laan Tungir
7dcfc7c3e3 Fix broadcast relay timeout: use 30s timeout for large broadcasts, distinguish timeouts from explicit rejections, only skip-mark explicit rejections, use live-tracked sets for results 2026-06-30 09:15:02 -04:00
Laan Tungir
07f5f39f35 Add per-mint tracing logs to handleWalletPayInvoice for lightning zap diagnostics 2026-06-30 07:59:52 -04:00
5 changed files with 248 additions and 39 deletions

View File

@@ -392,6 +392,7 @@ function handleWorkerMessage(event) {
failed: message.failed,
latestRelay: message.latestRelay,
latestError: message.latestError,
relayUrls: message.relayUrls || [],
}
}));
} else if (message.type === 'startupStatus') {

View File

@@ -915,16 +915,78 @@ export function formatTimeAgo(timestamp) {
}
// Store timestamps for real-time updates
const timeAgoElements = new Map(); // element -> timestamp
const timeAgoElements = new Map(); // element -> { timestamp, eventId }
// Track relay publish info per event ID from broadcastProgress events.
// Keyed by event ID → { count, urls } where urls is an array of relay URLs.
const relayInfoByEventId = new Map();
// Listen for broadcast progress events to capture final relay counts and URLs.
// The worker emits ndkBroadcastProgress with phase 'done' containing the
// total successful count and the list of relay URLs. We store it so post
// cards can show "21r - 1h" with a hover tooltip listing the relays.
if (typeof window !== 'undefined') {
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || d.phase !== 'done') return;
const eventId = d.eventId;
if (!eventId) return;
relayInfoByEventId.set(eventId, {
count: d.successful || 0,
urls: Array.isArray(d.relayUrls) ? d.relayUrls : [],
});
// Update any already-rendered time elements for this event.
timeAgoElements.forEach((info, element) => {
if (info.eventId === eventId && element.isConnected) {
updateTimeAgoElement(element, info);
}
});
});
}
/**
* Build the hover tooltip text for a relay list.
* Shows "Published to N relays:" followed by the URL list, truncated.
* @param {number} count - Number of successful relays
* @param {string[]} urls - Array of relay URLs
* @returns {string}
*/
function buildRelayTooltip(count, urls) {
if (!count || count === 0) return '';
const header = `Published to ${count} relay${count === 1 ? '' : 's'}:`;
if (!urls || urls.length === 0) return header;
// Show up to 50 relay URLs in the tooltip; beyond that, show a summary.
if (urls.length <= 50) {
return header + '\n' + urls.join('\n');
}
return header + '\n' + urls.slice(0, 50).join('\n') + `\n… and ${urls.length - 50} more`;
}
/**
* Update a time-ago element's text and tooltip from its stored info.
* @param {HTMLElement} element - The element to update
* @param {{timestamp: number, eventId: string}} info - Stored info
*/
function updateTimeAgoElement(element, info) {
const timeStr = formatTimeAgo(info.timestamp);
if (info.eventId && relayInfoByEventId.has(info.eventId)) {
const relayInfo = relayInfoByEventId.get(info.eventId);
element.textContent = `${relayInfo.count}r - ${timeStr}`;
element.title = buildRelayTooltip(relayInfo.count, relayInfo.urls);
} else {
element.textContent = timeStr;
}
}
/**
* Register an element for time updates
* @param {HTMLElement} element - The element to update
* @param {number} timestamp - Unix timestamp in seconds
* @param {string} [eventId] - Optional event ID for relay count display
*/
export function registerTimeAgo(element, timestamp) {
timeAgoElements.set(element, timestamp);
element.textContent = formatTimeAgo(timestamp);
export function registerTimeAgo(element, timestamp, eventId) {
timeAgoElements.set(element, { timestamp, eventId });
updateTimeAgoElement(element, { timestamp, eventId });
}
/**
@@ -932,9 +994,9 @@ export function registerTimeAgo(element, timestamp) {
* Call this periodically (e.g., every 30 seconds)
*/
export function updateTimeAgos() {
timeAgoElements.forEach((timestamp, element) => {
timeAgoElements.forEach((info, element) => {
if (element.isConnected) {
element.textContent = formatTimeAgo(timestamp);
updateTimeAgoElement(element, info);
} else {
// Clean up detached elements
timeAgoElements.delete(element);
@@ -1085,10 +1147,12 @@ export function renderFooterRow(eventId, eventData, options = {}) {
container.appendChild(nutzapItem);
container.appendChild(midControls);
// Time as the final item in the same row
// Time as the final item in the same row.
// Pass eventId so registerTimeAgo can prepend the relay count
// (e.g., "21r - 1h") when a broadcastProgress 'done' event arrives.
const timeEl = document.createElement('span');
timeEl.className = 'divPostTime';
registerTimeAgo(timeEl, eventData.created_at);
registerTimeAgo(timeEl, eventData.created_at, eventId);
container.appendChild(timeEl);
footerRow.appendChild(container);

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.66",
"VERSION_NUMBER": "0.7.66",
"BUILD_DATE": "2026-06-30T11:47:22.403Z"
"VERSION": "v0.7.76",
"VERSION_NUMBER": "0.7.76",
"BUILD_DATE": "2026-06-30T14:13:46.694Z"
}

View File

@@ -4568,6 +4568,16 @@ async function handleWalletCreateDeposit(requestId, amount, mint, port) {
}
async function handleWalletPayInvoice(requestId, invoice, mint, port) {
const startedAt = Date.now();
const traceId = `walletPayInvoice:${requestId}`;
const log = (phase, extra = null) => {
if (extra === null) {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`);
} else {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`, extra);
}
};
try {
await ensureDirectWalletLoaded();
@@ -4580,6 +4590,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
? [requestedMint, ...knownMints.filter((m) => m !== requestedMint)]
: [...knownMints].sort((a, b) => getProofTotal(directProofStore[b]) - getProofTotal(directProofStore[a]));
log('mint-selection', {
requestedMint: requestedMint || null,
orderedMints: orderedMints.map((m) => ({ mint: m, proofBalance: getProofTotal(directProofStore[m]) }))
});
let payResult = null;
let usedMint = null;
let paidAmountSats = 0;
@@ -4588,7 +4603,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
for (const mintUrl of orderedMints) {
try {
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
const proofBalance = getProofTotal(proofs);
log('mint attempt:start', { mintUrl, proofBalance, proofCount: proofs.length });
if (proofs.length === 0) {
log('mint attempt:no-proofs', { mintUrl });
attemptErrors.push({ mint: mintUrl, message: 'No proofs available for mint' });
continue;
}
@@ -4597,44 +4616,60 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
const meltQuote = await wallet.createMeltQuote(pr);
const quoteAmount = Number(meltQuote?.amount || 0);
const quoteFeeReserve = Number(meltQuote?.fee_reserve || 0);
// The mint's quoted fee_reserve is an estimate; the actual melt fee can be
// slightly higher, causing "not enough inputs provided for melt" errors.
// Add a safety buffer — any overpayment is returned as change proofs.
const FEE_RESERVE_BUFFER_SATS = 10;
const required = quoteAmount + quoteFeeReserve;
const sendAmount = required + FEE_RESERVE_BUFFER_SATS;
log('mint attempt:quote', { mintUrl, quoteAmount, quoteFeeReserve, required, sendAmount });
if (!Number.isFinite(required) || required <= 0) {
log('mint attempt:invalid-quote', { mintUrl, required });
attemptErrors.push({ mint: mintUrl, message: 'Invalid melt quote amount' });
continue;
}
const proofBalance = getProofTotal(proofs);
if (proofBalance < required) {
if (proofBalance < sendAmount) {
log('mint attempt:insufficient', { mintUrl, proofBalance, sendAmount });
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
continue;
}
const sendResult = await wallet.send(required, proofs);
const sendResult = await wallet.send(sendAmount, proofs);
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
log('mint attempt:send-split', { mintUrl, keepCount: keep.length, sendCount: sendProofs.length, sendAmount });
const meltResult = await wallet.meltProofs(meltQuote, sendProofs);
const change = Array.isArray(meltResult?.change) ? meltResult.change : [];
upsertMintProofs(mintUrl, [...keep, ...change]);
const changeAmount = getProofTotal(change);
const spentAmount = Math.max(0, required - changeAmount);
const spentAmount = Math.max(0, sendAmount - changeAmount);
paidAmountSats = Number.isFinite(quoteAmount) && quoteAmount > 0
? quoteAmount
: spentAmount;
log('mint attempt:success', { mintUrl, paidAmountSats, changeAmount, spentAmount, preimage: meltResult?.preimage || null });
usedMint = mintUrl;
payResult = meltResult;
break;
} catch (error) {
log('mint attempt:error', { mintUrl, message: error?.message || String(error) });
attemptErrors.push({ mint: mintUrl, message: error?.message || String(error) });
}
}
if (!payResult || !usedMint) {
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
log('all-mints-failed', { attemptErrors });
throw new Error(`Failed to pay invoice. ${detail || 'No mint could complete melt'}`);
}
log('paid', { usedMint, paidAmountSats, preimage: payResult?.preimage || null });
const tx = appendDirectTransaction({
type: 'pay',
amount: Number.isFinite(paidAmountSats) && paidAmountSats > 0
@@ -4680,6 +4715,7 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
}
})();
} catch (error) {
log('error', { message: error?.message || String(error), stack: error?.stack || null });
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
@@ -6168,6 +6204,20 @@ async function handlePublish(requestId, event, port) {
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
if (NDKRelaySet?.fromRelayUrls) {
targetRelaySet = NDKRelaySet.fromRelayUrls(allUrls, ndk);
// Set the connection timeout on all relays in the set to match
// the per-relay publish timeout. NDK's default
// connectionTimeout is 4400ms — we increase it so relays have
// time to connect even when hundreds are competing for browser
// connection slots.
if (targetRelaySet?.relays) {
for (const relay of targetRelaySet.relays) {
try {
if (relay.connectionTimeout !== undefined) {
relay.connectionTimeout = 60000;
}
} catch (_) { /* relay may be read-only */ }
}
}
console.log(`[Worker] Broadcasting to ${allUrls.length} relays ` +
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
`${skippedRelayUrls.size} skipped)`);
@@ -6183,7 +6233,8 @@ async function handlePublish(requestId, event, port) {
// broadcast() so footers can show "📡 42/150 relays · relay.example.com".
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const publishedSoFar = new Set();
const failedSoFar = new Set();
const failedSoFar = new Set(); // explicit rejections (auth-required, blocked, etc.)
const timedOutSoFar = new Set(); // timeouts — NOT skip-marked (could be slow connect)
const totalTarget = targetRelaySet && targetRelaySet.relays
? targetRelaySet.relays.size
: (targetRelaySet ? (targetRelaySet.size || 0) : 0);
@@ -6220,33 +6271,91 @@ async function handlePublish(requestId, event, port) {
ndkEvent.on('relay:publish:failed', (relay, err) => {
const url = relay?.url || String(relay || '');
if (!url) return;
failedSoFar.add(url);
const errMsg = err?.message || String(err) || '';
// Distinguish timeouts from explicit rejections. Timeouts with
// hundreds of relays often just mean the relay was slow to connect,
// not that it's broken — so we track them separately and do NOT
// skip-mark them. Only explicit rejections go into failedSoFar.
const isTimeout = /timeout/i.test(errMsg);
if (isTimeout) {
timedOutSoFar.add(url);
} else {
failedSoFar.add(url);
}
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: url,
latestError: err?.message || String(err),
latestError: errMsg,
});
});
}
// Publish to relays (with broadcast relay set if broadcasting, else default outbox)
const relaySet = await ndkEvent.publish(targetRelaySet);
// Publish to relays (with broadcast relay set if broadcasting, else default outbox).
// Per-relay timeout for broadcasts: 60 seconds. NDK may not be able to handle
// hundreds of simultaneous WebSocket connections in true parallel — the browser
// has connection limits and NDK's pool may serialize some operations. A 60s
// per-relay timeout gives each relay ample time to connect and publish even when
// queued behind hundreds of others.
// requiredRelayCount=1 so NDK doesn't throw if only 1 of 600 relays succeeds.
const BROADCAST_PER_RELAY_TIMEOUT_MS = 60000;
const publishTimeoutMs = isBroadcast ? BROADCAST_PER_RELAY_TIMEOUT_MS : undefined;
const publishRequiredCount = isBroadcast ? 1 : undefined;
let relaySet;
if (isBroadcast) {
// For broadcasts, race the publish against an overall deadline.
// NDK's publish() uses Promise.all internally, so it waits for ALL
// relays to resolve. The overall deadline is totalRelays × perRelayTimeout
// as a safety cap. With 600 relays × 60s = 36000s (10 hours) — this is
// effectively "wait until all relays finish" since the deadline will never
// fire before the per-relay timeouts resolve all promises.
const BROADCAST_OVERALL_DEADLINE_MS = Math.max(30000, totalTarget * BROADCAST_PER_RELAY_TIMEOUT_MS);
const publishPromise = ndkEvent.publish(targetRelaySet, publishTimeoutMs, publishRequiredCount)
.catch((err) => {
// NDKPublishError is expected when not all relays succeed —
// the live listeners already captured the successes.
console.warn('[Worker] Broadcast publish() resolved with error (expected):', err?.message || err);
return null;
});
const deadlinePromise = new Promise((resolve) => {
setTimeout(() => resolve('__DEADLINE__'), BROADCAST_OVERALL_DEADLINE_MS);
});
const result = await Promise.race([publishPromise, deadlinePromise]);
if (result === '__DEADLINE__') {
console.log(`[Worker] Broadcast overall deadline (${BROADCAST_OVERALL_DEADLINE_MS}ms) reached — ` +
`${publishedSoFar.size} succeeded, ${failedSoFar.size + timedOutSoFar.size} failed/timeout, ` +
`${totalTarget - publishedSoFar.size - failedSoFar.size - timedOutSoFar.size} still pending`);
}
relaySet = result && result !== '__DEADLINE__' ? result : null;
} else {
// Normal publish — await the full result as before.
try {
relaySet = await ndkEvent.publish(targetRelaySet, publishTimeoutMs, publishRequiredCount);
} catch (publishError) {
console.warn('[Worker] publish() threw:', publishError?.message || publishError);
relaySet = null;
}
}
// Final broadcast progress message with complete results.
if (isBroadcast && totalTarget > 0) {
const totalFailed = failedSoFar.size + timedOutSoFar.size;
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
failed: totalFailed,
latestRelay: null,
relayUrls: Array.from(publishedSoFar),
});
// Clean up listeners so the NDKEvent can be garbage-collected.
try {
@@ -6257,6 +6366,28 @@ async function handlePublish(requestId, event, port) {
}
}
// For non-broadcast publishes, emit a simplified broadcastProgress 'done'
// event so post cards can still show the relay count (e.g., "3r - 1h").
// We use the relaySet result to count successful relays.
if (!isBroadcast) {
const successCount = relaySet ? relaySet.size : 0;
const successUrls = relaySet
? Array.from(relaySet).map(r => r?.url || '').filter(Boolean)
: [];
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: successCount,
successful: successCount,
failed: 0,
latestRelay: null,
relayUrls: successUrls,
});
}
// Get detailed relay results
const relayResults = {
successful: [],
@@ -6272,32 +6403,49 @@ async function handlePublish(requestId, event, port) {
}
}
// Check which connected relays didn't accept it
for (const relay of ndk.pool.relays.values()) {
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
relayResults.failed.push(relay.url);
// For broadcast publishes, use the live-tracked sets from the
// relay:published / relay:publish:failed events as the source of truth
// (they cover temporary relays that may not be in ndk.pool). For normal
// publishes, fall back to the pool-scan logic.
if (isBroadcast) {
relayResults.successful = Array.from(publishedSoFar);
// Include both explicit rejections and timeouts in the failed list
// for display purposes, but keep them distinguishable for skip-marking.
relayResults.failed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
} else {
// Check which connected relays didn't accept it
for (const relay of ndk.pool.relays.values()) {
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
relayResults.failed.push(relay.url);
}
}
}
console.log('[Worker] ✅ Published to relays:', relayResults.successful);
console.log('[Worker] ❌ Failed relays:', relayResults.failed);
console.log('[Worker] ✅ Published to relays:', relayResults.successful.length, relayResults.successful.slice(0, 10), relayResults.successful.length > 10 ? `... +${relayResults.successful.length - 10} more` : '');
console.log('[Worker] ❌ Failed relays:', relayResults.failed.length);
// --- Failure marking for broadcast relays ---------------------------------
// Persist skip-marks for broadcast relays that failed this publish so
// future publishes skip them. Debounced via scheduleSkipMarkSave() so a
// single post that fails on many relays produces one kind 10088 republish.
// Persist skip-marks ONLY for broadcast relays that EXPLICITLY rejected
// the publish (auth-required, blocked, invalid, etc.). We do NOT skip-mark
// relays that timed out — with hundreds of relays, a timeout often means
// the relay was slow to connect, not that it's broken. The relay:publish:failed
// listener above separates timeouts (timedOutSoFar) from explicit rejections
// (failedSoFar) by checking the error message for "timeout".
if (isBroadcast) {
const newSkips = relayResults.failed
const newSkips = Array.from(failedSoFar)
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
skippedRelayUrls.set(url, {
reason: 'publish-failed',
reason: 'publish-rejected',
timestamp: Math.floor(Date.now() / 1000),
});
}
scheduleSkipMarkSave();
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays`);
console.log(`[Worker] Skip-marked ${newSkips.length} rejected broadcast relays ` +
`(${timedOutSoFar.size} timeouts excluded from skip-marking)`);
} else if (timedOutSoFar.size > 0) {
console.log(`[Worker] ${timedOutSoFar.size} broadcast relays timed out (not skip-marked — will retry next publish)`);
}
}

View File

@@ -833,12 +833,8 @@ import { initPostCards } from './js/post-interactions2.mjs';
: '';
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
} else if (d.phase === 'done') {
// Keep the result displayed until the next publish overwrites it.
spanBroadcastStatus.textContent = `${d.successful}/${d.total} relays` + (d.failed > 0 ? ` (${d.failed} failed)` : '');
setTimeout(() => {
if (spanBroadcastStatus.textContent.startsWith('✅')) {
spanBroadcastStatus.textContent = '';
}
}, 10000);
}
});