// Two kind-0 publishes at once used to clobber each other: both read the // same base off the relays, the later one landed without the earlier one's // fields. The migration did exactly that — the rename repointed lud16/nip05 // to the new name while the legacy-avatar import republished the old base // nine seconds later, so a user's published lightning address was the // placeholder name their rename had just released (Jarryd, 2026-10-24). // Now kind-0 publishes go one at a time and merge over what was last sent. // Run: bun tools/profile-publish-race-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, 160) : ''}`); 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; }, }; // One in-memory relay behind every socket: EVENT stores, REQ answers with // what matches (newest first) and EOSE. Enough for a kind-0 round trip. const store = []; const published = []; class FakeSocket { constructor(url) { this.url = 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') { store.push(m[1]); published.push(m[1]); this.reply(['OK', m[1].id, true, '']); } if (m[0] === 'REQ') { const [, sub, ...filters] = m; const hit = store.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; } }; // the legacy account the migration brings along globalThis.fetch = async (url) => { if (/coinos\.io\/api\/users\//.test(String(url))) return { ok: true, json: async () => ({ profile: 'face', banner: 'cover' }) }; throw new Error('offline'); }; const { messagesFeature } = await import('../src/features/messages.js'); const sk = generateSecretKey(); const pk = getPublicKey(sk); const signer = { pubkey: pk, signEvent: async (e) => finalizeEvent(e, sk) }; let now = Math.floor(Date.now() / 1000) - 100; const seed = finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Jarryd', lud16: 'jar@coinos.io', nip05: 'jar@coinos.io' }) }, sk); store.push(seed); const walletSk = generateSecretKey(); const wallet = { mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [], nostr: { sk: walletSk, pk: getPublicKey(walletSk), ck: new Uint8Array(32) }, loadFeatureState: () => null, saveFeatureState: () => {}, _cacheKey: () => 'race-test', saveCache: () => {}, registerCacheExtension: () => {}, registerLoadHook: () => {}, }; const feature = messagesFeature({ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }), ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null, hook: (name) => (name === 'nostrLoginIdentity' ? { pubkey: pk, signer } : null), }); console.log('[the migration: rename and avatar import fired together]'); await Promise.all([ feature.addressRenamed('jar@coinos.io', 'jarryd@coinos.io'), feature.adoptLegacyProfile('jarryd'), ]); const newest = store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0]; const final = JSON.parse(newest.content); const sent = [...new Map(published.filter((e) => e.kind === 0).map((e) => [e.id, e])).values()]; check('two kind 0s went out, one after the other', sent.length === 2 && sent[1].created_at > sent[0].created_at, sent.map((e) => e.created_at + ' ' + JSON.parse(e.content).lud16 + ' ' + (JSON.parse(e.content).picture ? 'face' : '-')).join(' → ')); check('the newest kind 0 carries the new address', final.lud16 === 'jarryd@coinos.io' && final.nip05 === 'jarryd@coinos.io', JSON.stringify(final)); check('...and the legacy face and cover', /face\.webp$/.test(final.picture || '') && /cover\.webp$/.test(final.banner || ''), final.picture + ' ' + final.banner); check('...and the name it had', final.name === 'Jarryd'); console.log('[a second rename right behind, before the relays answer]'); published.length = 0; await Promise.all([feature.addressRenamed('jarryd@coinos.io', 'jaz@coinos.io'), feature.publishProfile({ about: 'hi' })]); const last = JSON.parse(store.filter((e) => e.kind === 0 && e.pubkey === pk).sort((a, b) => b.created_at - a.created_at)[0].content); check('both changes survive', last.lud16 === 'jaz@coinos.io' && last.about === 'hi' && /face/.test(last.picture), JSON.stringify(last)); console.log(fails ? `\n❌ ${fails} failing` : '\n✅ kind-0 publishes take turns'); process.exit(fails ? 1 : 0);