// 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: 'Nikkusanagi@coinos.io', 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, 'npub16df0esf@coinos.io'); 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 === 'npub16df0esf@coinos.io' && c.lud16 === 'Nikkusanagi@coinos.io', 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: 'nicstazzema@coinos.io', nip05: 'nicstazzema@coinos.io', 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);