mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
fix(dm): stop the per-message extension decrypt storm in legacy DMs
The DM-repro harness (fake nip-07 extension) showed legacy DM history made ~12 extension decrypt round-trips PER message (20 DMs -> 241 calls: nip44.decrypt 121 + nip04.decrypt 120). At a real ~100ms extension latency that's ~24s, which is why DM history "looked broken" — it was just catastrophically slow. Two causes in decryptLegacyDm: - It raced EVERY (decryptor x remote-pubkey) combination in parallel. That looked like a latency win, but the nip-07 extension serializes calls, so racing N combinations just queued N slow round-trips per message. - orderDecryptors ranked nip44 first (by candidate priority), so every kind-4 (always NIP-04) message burned a failing nip44 extension call first. Fix: order nip04 first for legacy DMs (preferLegacy) and try candidates SEQUENTIALLY, stopping at the first success. With the right scheme and remote party tried first, it's ~1-3 calls/message instead of ~12. Harness after: 20 DMs -> 60 calls, zero nip44 attempts (was 241). DM unit + integration tests green. Adds scripts/perf/dm-repro.mjs (seeds kind-4 DMs, logs in via the fake extension, calls listDirectMessages, reports decrypted count + extension call counts). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
04156eb40c
commit
bbf8d10473
2 files changed
+172
-27
No files matched your search
+38
-27
@@ -213,7 +213,7 @@ function ensureDecryptCandidates(rawCandidates) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) {
|
||||
function orderDecryptors(candidates, hints, { preferGiftWrap = false, preferLegacy = false } = {}) {
|
||||
if (!candidates.length) {
|
||||
return [];
|
||||
}
|
||||
@@ -250,14 +250,14 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) {
|
||||
};
|
||||
|
||||
return [...candidates].sort((a, b) => {
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority;
|
||||
}
|
||||
|
||||
const aRank = desiredSchemeRank(a.scheme);
|
||||
const bRank = desiredSchemeRank(b.scheme);
|
||||
if (aRank !== bRank) {
|
||||
return aRank - bRank;
|
||||
// Legacy (kind 4) DMs are always NIP-04 — try nip04 candidates first so we
|
||||
// don't burn a (serialized, slow) extension round-trip on nip44 first.
|
||||
if (preferLegacy) {
|
||||
const aL = a.scheme === "nip04" ? 0 : 1;
|
||||
const bL = b.scheme === "nip04" ? 0 : 1;
|
||||
if (aL !== bL) {
|
||||
return aL - bL;
|
||||
}
|
||||
}
|
||||
|
||||
if (preferGiftWrap) {
|
||||
@@ -268,6 +268,16 @@ function orderDecryptors(candidates, hints, { preferGiftWrap = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (a.priority !== b.priority) {
|
||||
return a.priority - b.priority;
|
||||
}
|
||||
|
||||
const aRank = desiredSchemeRank(a.scheme);
|
||||
const bRank = desiredSchemeRank(b.scheme);
|
||||
if (aRank !== bRank) {
|
||||
return aRank - bRank;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
@@ -538,7 +548,7 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
});
|
||||
}
|
||||
|
||||
const ordered = orderDecryptors(decryptors, hints);
|
||||
const ordered = orderDecryptors(decryptors, hints, { preferLegacy: true });
|
||||
const errors = [];
|
||||
|
||||
const remoteCandidates = [];
|
||||
@@ -604,28 +614,29 @@ async function decryptLegacyDm(event, decryptors, actorPubkey) {
|
||||
}
|
||||
};
|
||||
|
||||
const attempts = [];
|
||||
// Try candidates sequentially and STOP at the first success. Racing every
|
||||
// (decryptor x remote) combination in parallel looked fast, but the nip-07
|
||||
// extension serializes calls — so racing N combinations just queued N slow
|
||||
// extension round-trips per message (the legacy DM history was ~12 extension
|
||||
// calls per message). With nip04 ordered first and the remote party first, the
|
||||
// first attempt normally succeeds, so this is ~1 call per message with no
|
||||
// worse latency-to-first-success on the serialized extension.
|
||||
for (const decryptor of ordered) {
|
||||
for (const remotePubkey of remoteCandidates) {
|
||||
attempts.push(attemptDecryption(decryptor, remotePubkey));
|
||||
try {
|
||||
return await attemptDecryption(decryptor, remotePubkey);
|
||||
} catch (failure) {
|
||||
errors.push(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Race all decryption attempts to return the first successful result
|
||||
// This significantly reduces latency compared to sequential attempts
|
||||
try {
|
||||
return await raceOrdered(attempts);
|
||||
} catch (aggregateError) {
|
||||
const aggregatedErrors = aggregateError.errors || [];
|
||||
errors.push(...aggregatedErrors);
|
||||
|
||||
return buildDecryptResult({
|
||||
ok: false,
|
||||
event,
|
||||
actorPubkey,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
return buildDecryptResult({
|
||||
ok: false,
|
||||
event,
|
||||
actorPubkey,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
export async function decryptDM(event, context = {}) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Focused reproduction of DM history, using the fake nip-07 extension.
|
||||
//
|
||||
// Seeds legacy (kind 4 / NIP-04) DMs between the user and a peer on a mock relay,
|
||||
// logs in via the fake extension, then calls nostrClient.listDirectMessages and
|
||||
// reports what comes back (decrypted messages, errors, extension call counts) —
|
||||
// so we can see whether DM history is broken vs slow, and why.
|
||||
//
|
||||
// Usage: node scripts/perf/dm-repro.mjs (env: DM_MSGS=20 LATENCY_MS=0)
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { startRelay } from "../agent/simple-relay.mjs";
|
||||
import { finalizeEvent, getPublicKey, generateSecretKey, nip04 } from "nostr-tools";
|
||||
import * as nip44 from "nostr-tools/nip44";
|
||||
|
||||
const APP = process.env.APP || "http://localhost:3000";
|
||||
const LATENCY_MS = Number(process.env.LATENCY_MS ?? 0);
|
||||
const DM_MSGS = Number(process.env.DM_MSGS ?? 20);
|
||||
const WS_PORT = 8962;
|
||||
|
||||
const USER_SK = Uint8Array.from(Buffer.from("11".repeat(32), "hex"));
|
||||
const USER_PK = getPublicKey(USER_SK);
|
||||
const PEER_SK = generateSecretKey();
|
||||
const PEER_PK = getPublicKey(PEER_SK);
|
||||
|
||||
let extCalls = {};
|
||||
let queue = Promise.resolve();
|
||||
function viaExtension(method, fn) {
|
||||
extCalls[method] = (extCalls[method] || 0) + 1;
|
||||
const run = async () => {
|
||||
if (LATENCY_MS > 0) await new Promise((r) => setTimeout(r, LATENCY_MS));
|
||||
return fn();
|
||||
};
|
||||
const p = queue.then(run, run);
|
||||
queue = p.catch(() => {});
|
||||
return p;
|
||||
}
|
||||
const convKey = (peerPk) => nip44.v2.utils.getConversationKey(USER_SK, peerPk);
|
||||
|
||||
async function main() {
|
||||
const relay = startRelay(WS_PORT, { httpPort: false });
|
||||
const relayUrl = `ws://127.0.0.1:${WS_PORT}`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
relay.seedEvent(finalizeEvent({ kind: 10002, created_at: now, tags: [["r", relayUrl]], content: "" }, USER_SK));
|
||||
|
||||
// Seed kind-4 DMs: peer -> user (received) and user -> peer (sent).
|
||||
for (let i = 0; i < DM_MSGS; i++) {
|
||||
const fromPeer = i % 2 === 0;
|
||||
const sk = fromPeer ? PEER_SK : USER_SK;
|
||||
const authorPk = fromPeer ? PEER_PK : USER_PK;
|
||||
const otherPk = fromPeer ? USER_PK : PEER_PK;
|
||||
const text = `DM message ${i} (${fromPeer ? "from peer" : "to peer"})`;
|
||||
const content = nip04.encrypt(sk, otherPk, text);
|
||||
relay.seedEvent(
|
||||
finalizeEvent(
|
||||
{ kind: 4, created_at: now - i * 60, tags: [["p", otherPk]], content },
|
||||
sk,
|
||||
),
|
||||
);
|
||||
}
|
||||
console.log(`Seeded ${DM_MSGS} kind-4 DMs. user=${USER_PK.slice(0, 10)} peer=${PEER_PK.slice(0, 10)}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
|
||||
const context = await browser.newContext();
|
||||
await context.exposeBinding("__extGetPublicKey", () => viaExtension("getPublicKey", () => USER_PK));
|
||||
await context.exposeBinding("__extSignEvent", (_s, e) => viaExtension("signEvent", () => finalizeEvent({ ...e, pubkey: USER_PK }, USER_SK)));
|
||||
await context.exposeBinding("__extNip04Encrypt", (_s, pk, t) => viaExtension("nip04.encrypt", () => nip04.encrypt(USER_SK, pk, t)));
|
||||
await context.exposeBinding("__extNip04Decrypt", (_s, pk, ct) => viaExtension("nip04.decrypt", () => nip04.decrypt(USER_SK, pk, ct)));
|
||||
await context.exposeBinding("__extNip44Encrypt", (_s, pk, t) => viaExtension("nip44.encrypt", () => nip44.v2.encrypt(t, convKey(pk))));
|
||||
await context.exposeBinding("__extNip44Decrypt", (_s, pk, ct) => viaExtension("nip44.decrypt", () => nip44.v2.decrypt(ct, convKey(pk))));
|
||||
await context.addInitScript((url) => {
|
||||
localStorage.setItem("hasSeenDisclaimer", "true");
|
||||
localStorage.setItem("__bitvidTestMode__", "1");
|
||||
localStorage.setItem("__bitvidTestRelays__", JSON.stringify([url]));
|
||||
localStorage.setItem("bitvid_admin_whitelist_mode", "false");
|
||||
const w = (n) => (...a) => window[n](...a);
|
||||
window.nostr = {
|
||||
getPublicKey: () => w("__extGetPublicKey")(),
|
||||
signEvent: (e) => w("__extSignEvent")(e),
|
||||
getRelays: async () => ({}),
|
||||
nip04: { encrypt: (p, t) => w("__extNip04Encrypt")(p, t), decrypt: (p, c) => w("__extNip04Decrypt")(p, c) },
|
||||
nip44: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c), v2: { encrypt: (p, t) => w("__extNip44Encrypt")(p, t), decrypt: (p, c) => w("__extNip44Decrypt")(p, c) } },
|
||||
};
|
||||
}, relayUrl);
|
||||
|
||||
const page = await context.newPage();
|
||||
const logs = [];
|
||||
page.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`.slice(0, 300)));
|
||||
page.on("pageerror", (e) => logs.push(`[pageerror] ${String(e).split("\n")[0]}`));
|
||||
|
||||
await page.goto(`${APP}/?__test__=1&__testRelays__=${encodeURIComponent(relayUrl)}`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => typeof window.__bitvidTest__ === "object", { timeout: 20000 }).catch(() => {});
|
||||
await page.evaluate((url) => window.__bitvidTest__?.setTestRelays?.([url], { persist: false }), relayUrl).catch(() => {});
|
||||
|
||||
// Login via fake extension.
|
||||
try {
|
||||
await page.click('[data-testid="login-button"]', { timeout: 8000 });
|
||||
await page.waitForSelector('[data-testid="login-modal"]', { timeout: 8000 });
|
||||
const extBtn = page.getByRole("button", { name: /extension|nip-?07|browser/i }).first();
|
||||
if (await extBtn.count()) await extBtn.click({ timeout: 5000 });
|
||||
else await page.locator('[data-testid="login-provider-button"]').first().click({ timeout: 5000 });
|
||||
} catch (_) {}
|
||||
await page.waitForFunction(async () => (await window.__bitvidTest__?.getAppState?.())?.isLoggedIn === true, { timeout: 15000 }).catch(() => {});
|
||||
|
||||
// Probe: list DMs directly via the client.
|
||||
const result = await page.evaluate(async (userPk) => {
|
||||
const c = window.__bitvidTest__?.nostrClient;
|
||||
if (!c || typeof c.listDirectMessages !== "function") return { ok: false, reason: "no-listDirectMessages" };
|
||||
try {
|
||||
const msgs = await c.listDirectMessages(userPk, { timeoutMs: 8000 });
|
||||
return {
|
||||
ok: true,
|
||||
count: Array.isArray(msgs) ? msgs.length : 0,
|
||||
sample: Array.isArray(msgs) ? msgs.slice(0, 3).map((m) => ({ ok: m?.ok, text: (m?.text || m?.content || "").slice(0, 40), from: (m?.pubkey || "").slice(0, 8) })) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
return { ok: false, reason: String(e).split("\n")[0] };
|
||||
}
|
||||
}, USER_PK);
|
||||
|
||||
console.log("\n================ DM REPRO ================");
|
||||
console.log(`listDirectMessages: ${JSON.stringify(result)}`);
|
||||
console.log(`Extension calls: ${JSON.stringify(extCalls)}`);
|
||||
const dm = logs.filter((l) => /dm|message|decrypt|permission|nip04|nip44|error|warn|fail|unavailable/i.test(l)).slice(0, 25);
|
||||
console.log(`\nConsole (DM-related):`);
|
||||
dm.forEach((l) => console.log(" " + l));
|
||||
console.log("=========================================\n");
|
||||
|
||||
await browser.close();
|
||||
if (typeof relay.close === "function") await relay.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error("dm-repro failed:", e); process.exit(1); });
|
||||
Reference in new issue
Block a user