diff --git a/js/auth/signingAdapter.js b/js/auth/signingAdapter.js index f7b8adc1..9d820518 100644 --- a/js/auth/signingAdapter.js +++ b/js/auth/signingAdapter.js @@ -3,6 +3,7 @@ import { signEventWithPrivateKey } from "../nostr/publishHelpers.js"; import { ensureNostrTools, getCachedNostrTools } from "../nostr/toolkit.js"; import { normalizeActorKey } from "../nostr/watchHistory.js"; import { buildHttpAuthEvent } from "../nostrEventSchemas.js"; +import { runNip07WithRetry } from "../nostr/nip07Permissions.js"; const DEFAULT_TEST_PUBKEY = "f".repeat(64); const DEFAULT_DEV_DISPLAY_NAME = "Ephemeral dev signer"; @@ -31,22 +32,37 @@ export function createNip07SigningAdapter({ extension } = {}) { (typeof window !== "undefined" && window?.nostr ? window.nostr : null); const getPubkey = async () => { - if (!resolvedExtension || typeof resolvedExtension.getPublicKey !== "function") { + if ( + !resolvedExtension || + typeof resolvedExtension.getPublicKey !== "function" + ) { throw new Error("NIP-07 extension is unavailable."); } - return normalizePubkey(await resolvedExtension.getPublicKey()); + return normalizePubkey( + await runNip07WithRetry(() => resolvedExtension.getPublicKey(), { + label: "extension.getPublicKey", + }), + ); }; const getDisplayName = async () => { - if (!resolvedExtension || typeof resolvedExtension.getMetadata !== "function") { + if ( + !resolvedExtension || + typeof resolvedExtension.getMetadata !== "function" + ) { return ""; } - const metadata = await resolvedExtension.getMetadata(); + const metadata = await runNip07WithRetry( + () => resolvedExtension.getMetadata(), + { label: "extension.getMetadata" }, + ); if (!metadata || typeof metadata !== "object") { return ""; } const displayName = - typeof metadata.display_name === "string" ? metadata.display_name.trim() : ""; + typeof metadata.display_name === "string" + ? metadata.display_name.trim() + : ""; if (displayName) { return displayName; } @@ -54,17 +70,27 @@ export function createNip07SigningAdapter({ extension } = {}) { }; const signEvent = async (event) => { - if (!resolvedExtension || typeof resolvedExtension.signEvent !== "function") { + if ( + !resolvedExtension || + typeof resolvedExtension.signEvent !== "function" + ) { throw new Error("NIP-07 extension is missing signEvent."); } - return resolvedExtension.signEvent(event); + return runNip07WithRetry(() => resolvedExtension.signEvent(event), { + label: "extension.signEvent", + }); }; const signMessage = async (message) => { - if (!resolvedExtension || typeof resolvedExtension.signMessage !== "function") { + if ( + !resolvedExtension || + typeof resolvedExtension.signMessage !== "function" + ) { throw new Error("NIP-07 extension does not support signMessage."); } - return resolvedExtension.signMessage(message); + return runNip07WithRetry(() => resolvedExtension.signMessage(message), { + label: "extension.signMessage", + }); }; return { diff --git a/js/nostr/nip07Permissions.js b/js/nostr/nip07Permissions.js index 60bb0cdb..181ecb84 100644 --- a/js/nostr/nip07Permissions.js +++ b/js/nostr/nip07Permissions.js @@ -30,6 +30,11 @@ export const DEFAULT_NIP07_PERMISSION_METHODS = Object.freeze([ ...DEFAULT_NIP07_ENCRYPTION_METHODS, ]); +// Module-level mutex to serialize NIP-07 extension requests. +// This prevents "message channel closed" errors caused by race conditions +// when multiple components (blocks, DMs, auth) query the extension simultaneously. +let nip07Queue = Promise.resolve(); + export function getEnableVariantTimeoutMs() { const overrideValue = typeof globalThis !== "undefined" && @@ -192,40 +197,61 @@ export async function runNip07WithRetry( retryMultiplier = 2, } = {}, ) { - // We wrap the operation invocation to ensure it returns a fresh promise each time - // we attempt it. This is critical for retries: if the first attempt stalls (dropped - // by extension), re-awaiting the same promise would just keep waiting on the dead request. - const invokeOperation = () => Promise.resolve(operation()); + // We wrap the entire retry logic in a queue task to ensure only one + // request hits the extension at a time. + const executeTask = async () => { + // We wrap the operation invocation to ensure it returns a fresh promise each time + // we attempt it. This is critical for retries: if the first attempt stalls (dropped + // by extension), re-awaiting the same promise would just keep waiting on the dead request. + const invokeOperation = () => Promise.resolve(operation()); - try { - return await withNip07Timeout(invokeOperation, { - timeoutMs, - message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, - }); - } catch (error) { - const isTimeoutError = - error instanceof Error && - error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE; + try { + return await withNip07Timeout(invokeOperation, { + timeoutMs, + message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + }); + } catch (error) { + const isTimeoutError = + error instanceof Error && + error.message === NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE; - if (!isTimeoutError || retryMultiplier <= 1) { - throw error; + if (!isTimeoutError || retryMultiplier <= 1) { + throw error; + } + + const extendedTimeout = Math.max( + timeoutMs, + Math.round(timeoutMs * retryMultiplier), + ); + + devLogger.warn( + `[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`, + ); + + // On retry, we invoke operation() again to send a fresh request to the extension. + return withNip07Timeout(invokeOperation, { + timeoutMs: extendedTimeout, + message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, + }); } + }; - const extendedTimeout = Math.max( - timeoutMs, - Math.round(timeoutMs * retryMultiplier), - ); + const queueItem = nip07Queue.then(() => + executeTask().then( + (res) => ({ ok: true, value: res }), + (err) => ({ ok: false, error: err }), + ), + ); - devLogger.warn( - `[nostr] ${label} taking longer than ${timeoutMs}ms. Retrying with ${extendedTimeout}ms timeout.`, - ); + // Advance the queue pointer + nip07Queue = queueItem.then(() => {}); - // On retry, we invoke operation() again to send a fresh request to the extension. - return withNip07Timeout(invokeOperation, { - timeoutMs: extendedTimeout, - message: NIP07_LOGIN_TIMEOUT_ERROR_MESSAGE, - }); + // Await the specific result for this call + const result = await queueItem; + if (result.ok) { + return result.value; } + throw result.error; } export async function requestEnablePermissions( diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 3850ead2..715f964f 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -19,6 +19,7 @@ import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js"; import { relayManager } from "../relayManager.js"; +import { runNip07WithRetry } from "../nostr/nip07Permissions.js"; const LOG_PREFIX = "[HashtagPreferences]"; const HASHTAG_IDENTIFIER = "bitvid:tag-preferences"; @@ -761,7 +762,11 @@ class HashtagPreferencesService { if (typeof nostrApi.nip04?.decrypt === "function") { registerDecryptor( "nip04", - (payload) => nostrApi.nip04.decrypt(userPubkey, payload), + (payload) => + runNip07WithRetry( + () => nostrApi.nip04.decrypt(userPubkey, payload), + { label: "nip04.decrypt" }, + ), "extension", ); } @@ -774,7 +779,10 @@ class HashtagPreferencesService { if (typeof nip44.decrypt === "function") { registerDecryptor( "nip44", - (payload) => nip44.decrypt(userPubkey, payload), + (payload) => + runNip07WithRetry(() => nip44.decrypt(userPubkey, payload), { + label: "nip44.decrypt", + }), "extension", ); } @@ -784,13 +792,19 @@ class HashtagPreferencesService { if (nip44v2 && typeof nip44v2.decrypt === "function") { registerDecryptor( "nip44_v2", - (payload) => nip44v2.decrypt(userPubkey, payload), + (payload) => + runNip07WithRetry(() => nip44v2.decrypt(userPubkey, payload), { + label: "nip44.v2.decrypt", + }), "extension", ); if (!decryptors.has("nip44")) { registerDecryptor( "nip44", - (payload) => nip44v2.decrypt(userPubkey, payload), + (payload) => + runNip07WithRetry(() => nip44v2.decrypt(userPubkey, payload), { + label: "nip44.v2.decrypt", + }), "extension", ); } diff --git a/js/userBlocks.js b/js/userBlocks.js index 07caba3c..2a348fbc 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -20,6 +20,7 @@ import { import { profileCache } from "./state/profileCache.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { relayManager } from "./relayManager.js"; +import { runNip07WithRetry } from "./nostr/nip07Permissions.js"; class TinyEventEmitter { constructor() { @@ -874,33 +875,50 @@ class UserBlockListManager { if (typeof nostrApi.nip04?.decrypt === "function") { registerDecryptor( "nip04", - (payload) => nostrApi.nip04.decrypt(normalized, payload), + (payload) => + runNip07WithRetry( + () => nostrApi.nip04.decrypt(normalized, payload), + { label: "nip04.decrypt" }, + ), "extension", ); } const nip44 = - nostrApi.nip44 && typeof nostrApi.nip44 === "object" ? nostrApi.nip44 : null; + nostrApi.nip44 && typeof nostrApi.nip44 === "object" + ? nostrApi.nip44 + : null; if (nip44) { if (typeof nip44.decrypt === "function") { registerDecryptor( "nip44", - (payload) => nip44.decrypt(normalized, payload), + (payload) => + runNip07WithRetry(() => nip44.decrypt(normalized, payload), { + label: "nip44.decrypt", + }), "extension", ); } - const nip44v2 = nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; + const nip44v2 = + nip44.v2 && typeof nip44.v2 === "object" ? nip44.v2 : null; if (nip44v2 && typeof nip44v2.decrypt === "function") { registerDecryptor( "nip44_v2", - (payload) => nip44v2.decrypt(normalized, payload), + (payload) => + runNip07WithRetry(() => nip44v2.decrypt(normalized, payload), { + label: "nip44.v2.decrypt", + }), "extension", ); if (!decryptors.has("nip44")) { registerDecryptor( "nip44", - (payload) => nip44v2.decrypt(normalized, payload), + (payload) => + runNip07WithRetry( + () => nip44v2.decrypt(normalized, payload), + { label: "nip44.v2.decrypt" }, + ), "extension", ); }