fix(nip46): route list decryption by ciphertext format (cut RPC fan-out)

Under a NIP-46 remote signer, every decrypt is a PUBLISHED relay RPC. The list
services (blocks/subscriptions/hashtags) probed all decryptor families
concurrently via Promise.any — so each list event published nip04 AND nip44 RPCs
at once. On login that flooded the relay ("rate-limited: you are noting too
much"), and the racing/queued RPCs blew past the 30s decrypt budget → the
"Decryption timed out" failures.

A NIP-04 payload carries the "?iv=" marker and can ONLY be decrypted by nip04;
otherwise it's nip44. The other family can never succeed on a given ciphertext,
so racing it is pure waste (and a wasted published RPC under NIP-46). Restrict
the decrypt probe to the family matching the ciphertext format (fall back to all
if the format is somehow unknown). Halves cold-load RPCs and stops the racing
publishes that tripped the rate limit; benefits all signer types.

Spec correction: the hashtag "retries decryptors when hinted scheme fails" test
stubbed an impossible scenario (the same ciphertext decrypting under nip04 after
nip44 failed). Rewritten to assert the real contract — the ciphertext format
routes decryption and overrides a misleading `encrypted` tag, invoking only the
matching decryptor.

test_integrity_note:
  change_type: ["spec_correction"]
  scenarios:
    - id: SCN-decrypt-format-routing
      given: "a nip04-format ciphertext (?iv=) tagged misleadingly as nip44"
      when: "a list service decrypts it"
      then: "only the nip04 decryptor is invoked; nip44 is never attempted"
  anti_cheat_rationale:
    prevents: ["over-mocking internal logic", "asserting impossible cross-family decryption"]
  relaxation:
    did_relax_any_assertion: false

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-17 12:13:58 -04:00
1 parent ef66092727
commit 8ab8dde7a7
4 files changed
+61 -8

No files matched your search

+14 -1
View File
@@ -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;
+14 -1
View File
@@ -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;
+16 -1
View File
@@ -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;
+17 -5
View File
@@ -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(), []);
},