aboutsummaryrefslogtreecommitdiffstats
path: root/tools/kind0-overwrite-test.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-30 18:29:14 -0700
committerAdam Soltys <[email protected]>2026-10-30 18:29:14 -0700
commitf675fef1321e69f434f076211d13438c0e1aff59 (patch)
treee07ff6ba80faa9738dccf9bb2c1135d0c8e4c918 /tools/kind0-overwrite-test.js
parente7961f2e0d9df26b846c2c7f976c0d567ffbcc15 (diff)
downloadcoinosv3-f675fef1321e69f434f076211d13438c0e1aff59.tar.gz
coinosv3-f675fef1321e69f434f076211d13438c0e1aff59.zip
A profile (kind 0) is never written over one we failed to find. 2026-10-30, a long-time nostr user signed in with his key for the first time; our relays had never seen his kind 0 (it lived on ditto, snort and oxtr), the 3 s lookup came back empty, and the payment-address publish went out as {lud16, nip05} alone: name, picture and banner gone from every relay (nicstazzema, shown as npub1yk2lkr7). An empty quick lookup now widens to the same search the follow list got after its own overwrite (indexes, archives, the key's own write relays, Primal's signed copy) before anything is written, once per key per session so a brand-new key's first publish waits once. For anyone already hit, the profile editor offers "Look for older copies": a kind 0 from a history-keeping relay fills only the fields still empty in the editor, and Save publishes it. tools/kind0-overwrite-test.js plays the incident against a fake relay network where only an archive holds the profile
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools/kind0-overwrite-test.js')
-rw-r--r--tools/kind0-overwrite-test.js141
1 files changed, 141 insertions, 0 deletions
diff --git a/tools/kind0-overwrite-test.js b/tools/kind0-overwrite-test.js
new file mode 100644
index 0000000..e791560
--- /dev/null
+++ b/tools/kind0-overwrite-test.js
@@ -0,0 +1,141 @@
+// A profile (kind 0) must never be written over one we failed to find.
+//
+// 2026-10-30: a long-time nostr user signed in to coinos with his key for
+// the first time. His kind 0 lived on relays that keep history (ditto,
+// snort, oxtr) and nowhere our quick lookup asked, so the payment-address
+// publish merged over an empty base and went out as {lud16, nip05} alone:
+// name, picture and banner gone from every relay. These scenarios run the
+// messages feature against a fake relay network where only an archive holds
+// the real profile, and record every event the wallet publishes.
+// Run: bun tools/kind0-overwrite-test.js
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 200) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+
+// per-relay stores: REQ answered from the socket's own relay, EVENT stored there
+const relayStore = {};
+const published = []; // { url, ev }
+const reqs = []; // { url, filter }
+const norm = (u) => String(u).replace(/\/$/, '');
+class FakeSocket {
+ constructor(url) {
+ this.url = norm(url); this.readyState = 0;
+ setTimeout(() => { this.readyState = 1; this.onopen && this.onopen({}); }, 0);
+ }
+ reply(msg) { setTimeout(() => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify(msg) }); }, 0); }
+ send(str) {
+ let m; try { m = JSON.parse(str); } catch { return; }
+ if (m[0] === 'EVENT') { (relayStore[this.url] ||= []).push(m[1]); published.push({ url: this.url, ev: m[1] }); this.reply(['OK', m[1].id, true, '']); }
+ if (m[0] === 'REQ') {
+ const [, sub, ...filters] = m;
+ for (const f of filters) reqs.push({ url: this.url, filter: f });
+ if (filters.some((f) => f.cache)) { this.reply(['EOSE', sub]); return; } // Primal's cache knows nothing here
+ const hit = (relayStore[this.url] || []).filter((e) => filters.some((f) => (!f.kinds || f.kinds.includes(e.kind)) && (!f.authors || f.authors.includes(e.pubkey))));
+ hit.sort((a, b) => b.created_at - a.created_at);
+ for (const e of hit) this.reply(['EVENT', sub, e]);
+ this.reply(['EOSE', sub]);
+ }
+ }
+ close() { this.readyState = 3; this.onclose && this.onclose({}); }
+}
+globalThis.WebSocket = FakeSocket;
+globalThis.Image = class { set src(v) { this._src = v; } };
+globalThis.fetch = async () => { throw new Error('offline'); };
+
+const { messagesFeature } = await import('../src/features/messages.js');
+
+const ARCHIVE = 'wss://relay.ditto.pub';
+const makeFeature = (sk, ui = {}) => {
+ const pk = getPublicKey(sk);
+ const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) };
+ const walletSk = generateSecretKey();
+ const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], nostrRelays: () => [],
+ nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) },
+ loadFeatureState: () => null, saveFeatureState: () => {},
+ _cacheKey: () => 'k0-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {},
+ };
+ const toasts = [];
+ const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui, render: () => {}, wallet, toast: (m) => toasts.push(m), brandHeader: () => null,
+ hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null),
+ });
+ return { feature, pk, toasts };
+};
+const newestOn = (url, pk) => (relayStore[url] || []).filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0];
+const kind0s = () => published.filter((p) => p.ev.kind === 0);
+
+console.log('[a first sign-in: the real profile lives only on an archive relay]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const old = { name: 'Nik kusanagi', display_name: 'Nik', lud16: '[email protected]', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
+ relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
+ const { feature } = makeFeature(sk);
+ // the payment-address publish that wiped him (lud16/nip05 empty or at the old address → repoint)
+ await feature.addressRenamed(null, '[email protected]');
+ const sent = [...new Set(kind0s().map((p) => p.ev.id))];
+ check('one kind 0 went out', sent.length === 1, sent.length);
+ const c = sent.length ? JSON.parse(kind0s()[kind0s().length - 1].ev.content) : {};
+ check('it fills the empty nip05 and leaves the lightning address he had', c.nip05 === '[email protected]' && c.lud16 === '[email protected]', JSON.stringify(c));
+ check('...and carries the name, face and cover the archive held', c.name === 'Nik kusanagi' && /face/.test(c.picture) && /cover/.test(c.banner), JSON.stringify(c));
+ check('the archive\'s copy was asked for', reqs.some((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)));
+
+ // the onboarding wizard's suggestion must not rename him either
+ published.length = 0;
+ await feature.publishProfile({ name: 'satoshi1234', picture: 'https://punk/1.png' }, { fillOnly: ['name', 'picture'] });
+ check('a fill-only suggestion changes nothing on a profile that has a name', kind0s().length === 0, kind0s().length);
+}
+
+console.log('[a key with no profile anywhere: one deep look, then no waiting]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const { feature } = makeFeature(sk);
+ published.length = 0; reqs.length = 0;
+ const t0 = Date.now();
+ await feature.publishProfile({ name: 'newbie' });
+ const deep1 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
+ await feature.publishProfile({ about: 'hello' });
+ const deep2 = reqs.filter((r) => r.url === ARCHIVE && r.filter.kinds && r.filter.kinds.includes(0) && r.filter.authors && r.filter.authors.includes(pk)).length;
+ const last = JSON.parse(kind0s().pop().ev.content);
+ check('the new key still gets its profile', last.name === 'newbie' && last.about === 'hello', JSON.stringify(last));
+ check('the archive was asked once, not again for the second publish', deep1 === 1 && deep2 === 1, `${deep1} ${deep2} in ${Date.now() - t0} ms`);
+}
+
+console.log('[already hit: the editor finds the older copy and fills what is empty]');
+{
+ const sk = generateSecretKey();
+ const pk = getPublicKey(sk);
+ const old = { name: 'Nik kusanagi', display_name: 'Nik', picture: 'https://coinos.io/api/public/face.webp', banner: 'https://coinos.io/api/public/cover.webp' };
+ relayStore[ARCHIVE] = [finalizeEvent({ kind: 0, created_at: 1760302726, tags: [], content: JSON.stringify(old) }, sk)];
+ // what the wipe left: the address and a picture he re-uploaded by hand
+ const wiped = { lud16: '[email protected]', nip05: '[email protected]', picture: 'https://image.nostr.build/new.png' };
+ for (const u of ['wss://relay.coinos.io', 'wss://relay.primal.net', 'wss://purplepag.es', ARCHIVE]) (relayStore[u] ||= []).push(finalizeEvent({ kind: 0, created_at: 1793399187, tags: [], content: JSON.stringify(wiped) }, sk));
+ const ui = { profEdit: { uname: 'nicstazzema', name: '', about: '', picture: wiped.picture, banner: '' } };
+ const { feature, toasts } = makeFeature(sk, ui);
+ const versions = await feature.profileHistory();
+ check('both copies found', versions.length === 2, versions.map((e) => e.created_at).join(' '));
+ const older = versions.find((e) => /Nik/.test(e.content));
+ const n = feature.fillProfileFrom(older);
+ check('name and banner filled, the picture he chose kept', n === 2 && ui.profEdit.name === 'Nik' && ui.profEdit.banner === old.banner && ui.profEdit.picture === wiped.picture, JSON.stringify(ui.profEdit));
+ check('says what it filled', toasts.some((m) => /Filled 2 fields/.test(m)), toasts.join(' | '));
+ published.length = 0;
+ await feature.saveProfileDraft(ui.profEdit);
+ const saved = JSON.parse(kind0s().pop().ev.content);
+ check('Save publishes the name back with the address intact', saved.name === 'Nik' && saved.display_name === 'Nik' && saved.lud16 === wiped.lud16 && /new\.png/.test(saved.picture) && /cover/.test(saved.banner), JSON.stringify(saved));
+ check('newer than the wiped copy everywhere', newestOn('wss://relay.coinos.io', pk).created_at > 1793399187);
+}
+
+console.log(fails ? `\n❌ ${fails} failing` : '\n✅ profiles are never written over unseen');
+process.exit(fails ? 1 : 0);