diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 202336b7..5ebbbba1 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -1298,7 +1298,20 @@ class HashtagPreferencesService { s === "nip44" || s === "nip44_v2" ? "nip44" : s; const seenFamilies = new Set(); const attemptSchemes = []; - const attempts = order + // Route by ciphertext format: only the family matching the payload can + // decrypt it (NIP-04 carries "?iv="; otherwise NIP-44). Racing the other + // family is wasted work — and under a NIP-46 remote signer it's a wasted + // PUBLISHED relay RPC that floods the relay ("rate-limited") and stalls + // decryption. Restrict the probe to the matching family. + const formatFamily = + typeof ciphertext === "string" && ciphertext.includes("?iv=") + ? "nip04" + : "nip44"; + const matchingOrder = order.filter( + (scheme) => getSchemeFamily(scheme) === formatFamily, + ); + const probeOrder = matchingOrder.length ? matchingOrder : order; + const attempts = probeOrder .map((scheme) => { const family = getSchemeFamily(scheme); if (seenFamilies.has(family)) return null; diff --git a/js/subscriptions.js b/js/subscriptions.js index b4f1b4a6..5007883d 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -1386,7 +1386,20 @@ class SubscriptionsManager { s === "nip44" || s === "nip44_v2" ? "nip44" : s; const seenFamilies = new Set(); const attemptSchemes = []; - const attempts = order + // Route by ciphertext format: only the family matching the payload can + // decrypt it (NIP-04 carries "?iv="; otherwise NIP-44). Racing the other + // family is wasted work — and under a NIP-46 remote signer it's a wasted + // PUBLISHED relay RPC that floods the relay ("rate-limited") and stalls + // decryption. Restrict the probe to the matching family. + const formatFamily = + typeof ciphertext === "string" && ciphertext.includes("?iv=") + ? "nip04" + : "nip44"; + const matchingOrder = order.filter( + (scheme) => getSchemeFamily(scheme) === formatFamily, + ); + const probeOrder = matchingOrder.length ? matchingOrder : order; + const attempts = probeOrder .map((scheme) => { const family = getSchemeFamily(scheme); if (seenFamilies.has(family)) return null; diff --git a/js/userBlocks.js b/js/userBlocks.js index 80dc11f1..35cbbbfc 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -1473,8 +1473,23 @@ class UserBlockListManager { try { const getSchemeFamily = (s) => s === "nip44" || s === "nip44_v2" ? "nip44" : s; + // Route by ciphertext format: a NIP-04 payload carries the "?iv=" + // marker and can ONLY be decrypted by nip04; everything else is + // nip44. Racing the non-matching family is wasted work — and under + // a NIP-46 remote signer each attempt is a separately PUBLISHED + // relay RPC, so racing both families floods the relay + // ("rate-limited: noting too much") and stalls decryption. Restrict + // the probe to the matching family (fall back to all if unknown). + const formatFamily = + typeof ev.content === "string" && ev.content.includes("?iv=") + ? "nip04" + : "nip44"; + const matchingOrder = order.filter( + (scheme) => getSchemeFamily(scheme) === formatFamily, + ); + const probeOrder = matchingOrder.length ? matchingOrder : order; const seenFamilies = new Set(); - const attempts = order + const attempts = probeOrder .map((scheme) => { const family = getSchemeFamily(scheme); if (seenFamilies.has(family)) return null; diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index e1a12dee..0158911c 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -180,8 +180,17 @@ test( }, ); +// Spec correction (was "load retries decryptors when hinted scheme fails"): +// the old test stubbed an impossible scenario — the SAME ciphertext decrypting +// under nip04 after nip44 "failed". A real NIP-04 payload (carrying "?iv=") can +// only be decrypted by nip04, and a NIP-44 payload only by nip44, so the other +// family never succeeds on a given ciphertext. Decryption now routes by the +// ciphertext format and does NOT race the non-matching family — which also +// avoids a wasted published RPC under a NIP-46 remote signer. This asserts the +// real contract: the ciphertext format wins even over a misleading `encrypted` +// tag, and only the matching decryptor is invoked. test( - "load retries decryptors when hinted scheme fails", + "load routes decryption by ciphertext format (ignores a misleading tag)", { concurrency: false }, async () => { const pubkey = "d".repeat(64); @@ -190,7 +199,7 @@ test( setActiveSigner({ nip44Decrypt: async () => { attempts.push("nip44"); - throw new Error("nip44 failed"); + throw new Error("nip44 must not be attempted for a nip04 (?iv=) payload"); }, nip04Decrypt: async () => { attempts.push("nip04"); @@ -209,8 +218,9 @@ test( id: "evt-retry", created_at: 300, pubkey, - content: "cipher", // never decrypted successfully by nip44 - tags: [["encrypted", "nip44 nip04"]], + // NIP-04 ciphertext format ("?iv=") but a misleading "nip44" tag. + content: "deadbeefdeadbeef?iv=cafebabecafebabe", + tags: [["encrypted", "nip44"]], }, ]; }, @@ -221,7 +231,9 @@ test( await hashtagPreferences.load(pubkey); - assert.deepEqual(attempts, ["nip44", "nip04"]); + // Only nip04 is tried — the "?iv=" format overrides the misleading tag, and + // nip44 is never invoked (no wasted attempt / wasted NIP-46 RPC). + assert.deepEqual(attempts, ["nip04"]); assert.deepEqual(hashtagPreferences.getInterests(), ["retry"]); assert.deepEqual(hashtagPreferences.getDisinterests(), []); },