diff options
| author | Adam Soltys <[email protected]> | 2026-10-23 22:39:04 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-23 22:39:04 -0700 |
| commit | ce96364662451556e9ae6fe8222eba4ea23bd27c (patch) | |
| tree | ccd16d1487581a405bc24f87352bbacbc959b69d /src/features/messages.js | |
| parent | b67ad2026286f6383f24b1aa9694bbeee571276b (diff) | |
| download | coinosv3-ce96364662451556e9ae6fe8222eba4ea23bd27c.tar.gz coinosv3-ce96364662451556e9ae6fe8222eba4ea23bd27c.zip | |
Amber sign-in on Android works past the first request, and a migrated profile no longer keeps its released placeholder address
Jarryd (DM, 2026-10-24): on Android he could not sign in with Amber and had to paste his nsec, and his profile page showed the temporary name he had picked while the editor showed his real one.
Amber: Chrome lets a page open another app only on the heels of a tap. The Sign-in tap carried the get_public_key request, but the request the login fires once the key is back (decrypt the backup, sign the relay list…) had no tap behind it, so Chrome dropped the nostrsigner: navigation with a console warning the page never sees ("Navigation is blocked") and the card spun forever. Reproduced on Adam's Pixel 9 with Amber 6.6.9 (Amber 4.1.3 did not even parse the web request). Now a request without a user activation behind it waits for one: the card says the signer has another request and offers an "Open signer app" button that launches it from the tap; a launch that leaves the page in front (never hidden) is treated as blocked too and gets the same button. The clipboard is still read on return (that part worked).
Profile: the migration released the placeholder name and repointed lud16/nip05 to the new one, while the legacy-avatar import ran at the same moment, read the same old kind 0, and republished it nine seconds later with the placeholder address back ([email protected], dead on both coinos.io and v3). Kind-0 publishes now go one at a time, merge over the newest copy this session sent, and are stamped strictly later than it (relays keep the lower id of two with the same second). The profile page shows the claimed address instead of a same-domain name that is no longer ours, and once the domain confirms the old name is dead it repoints the kind 0 in the background (healStaleAddress), so zaps to the published lightning address reach the wallet again.
tools/profile-publish-race-test.js plays the migration's two publishes at once against an in-memory relay. tools/amber-login-test.js and identity-test pass; name-migration-test and avatar-profile-test fail the same way on the previous commit.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 107 |
1 files changed, 80 insertions, 27 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index a35c0da..c82bd0e 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -12587,12 +12587,32 @@ export function messagesFeature(ctx) { ...noteOverlays()); } - async function publishProfileFields(fields, opts = {}) { + // Every kind-0 publish merges over the newest copy on the relays, so two + // publishes in flight at once clobber each other: both read the same base, + // the later one lands without the earlier one's fields. That exact race + // put a migrated user's released placeholder name back into their lud16 + // (the rename repointed it, the legacy-avatar import republished the old + // base nine seconds later). One at a time, and the base is never older + // than what this session last wrote. + let kind0Chain = Promise.resolve(); + let kind0Last = null; // { at, content } — the newest kind 0 we published + const withKind0Lock = (fn) => { const p = kind0Chain.then(fn, fn); kind0Chain = p.catch(() => {}); return p; }; + async function newestKind0(pk) { + const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [pk] }, 3000); + let newest = evs.sort((a, b) => b.created_at - a.created_at)[0] || null; + if (kind0Last && kind0Last.pk === pk && (!newest || newest.created_at <= kind0Last.at)) newest = kind0Last; + let base = {}; + try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + return base; + } + // Strictly later than the last one we sent: relays keep the LOWEST id of + // two replaceable events with the same second, so a second publish in + // the same second as the first could lose to it. + const kind0Stamp = (pk) => { const now = Math.floor(Date.now() / 1000); return kind0Last && kind0Last.pk === pk ? Math.max(now, kind0Last.at + 1) : now; }; + function publishProfileFields(fields, opts = {}) { return withKind0Lock(() => publishProfileFieldsNow(fields, opts)); } + async function publishProfileFieldsNow(fields, opts = {}) { const id = await requireIdentity(); - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); if (opts.onlyWhen && !opts.onlyWhen(base)) return true; // condition says leave it be const merged = { ...base }; // fillOnly fields are offers, not orders: the onboarding wizard suggests @@ -12608,10 +12628,11 @@ export function messagesFeature(ctx) { } if (merged.name && !(fill.has('name') && taken('name'))) merged.display_name = merged.name; if (JSON.stringify(merged) === JSON.stringify(base)) return true; // nothing to say - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null, t: Date.now() }); return true; @@ -12625,11 +12646,20 @@ export function messagesFeature(ctx) { ui.profSaving = true; render(); try { + await withKind0Lock(() => saveProfileNow(id, e)); + ui.profEdit = null; ui.profEditFilled = false; + toast(t('profSaved')); + } catch (err) { + if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; + toast(err.message || String(err)); + } finally { + ui.profSaving = false; + render(); + } + } + async function saveProfileNow(id, e) { // merge over the newest published kind 0 so unknown fields round-trip - const evs = await queryOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], { kinds: [0], authors: [id.pubkey] }, 3000); - const newest = evs.sort((a, b) => b.created_at - a.created_at)[0]; - let base = {}; - try { base = newest ? JSON.parse(newest.content) : {}; } catch {} + const base = await newestKind0(id.pubkey); // A changed username claims first — if the name is taken (or invalid) // the save stops here and the editor stays open to fix it. const oldAddr = hook('namesAddress'); @@ -12653,22 +12683,46 @@ export function messagesFeature(ctx) { if (!merged[k] || merged[k] === oldAddr) merged[k] = addr; } if (merged.name) merged.display_name = merged.name; - const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: Math.floor(Date.now() / 1000) }; + const partial = { kind: 0, content: JSON.stringify(merged), tags: [], created_at: kind0Stamp(id.pubkey) }; const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial); const ok = await publishOn([...new Set([...PROFILE_RELAYS, ...DM_RELAYS])], evt); if (!ok) throw new Error(t('msgSendFailed')); + kind0Last = { pk: id.pubkey, at: evt.created_at, content: evt.content }; fullProfiles.set(id.pubkey, merged); profiles.set(id.pubkey, { name: merged.name || null, picture: merged.picture || null }); - ui.profEdit = null; ui.profEditFilled = false; - toast(t('profSaved')); - } catch (err) { - if (ui.profEdit === e && e.uname === err.migrationName) e.migrationName = err.migrationName; - toast(err.message || String(err)); - } finally { - ui.profSaving = false; - render(); + } + // A payment-address rename released the old name: repoint the kind 0's + // lud16 and nip05 — but only where they pointed at the released address + // (or were empty). Deliberately different values are not ours to touch. + function repointAddress(oldAddr, newAddr) { + const follows = (k) => (base) => !base[k] || base[k] === oldAddr; + return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { + onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), + fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, + }); + } + // Our kind 0 still names an address at our own domain that is not the one + // we hold — a placeholder name released by a migration whose repoint lost + // the race above. Repointed once the domain confirms the old name is dead; + // a same-domain name that still answers (a second account, say) is left + // alone. Once per address per session. + const addrHealed = new Set(); + async function healStaleAddress(olds, myAddr) { + for (const old of olds) { + if (addrHealed.has(old)) continue; + addrHealed.add(old); + try { if (await addressDead(old)) await repointAddress(old, myAddr); } catch {} } } + async function addressDead(addr) { + const [name, domain] = addr.split('@'); + try { + const r = await fetch(`https://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`, { cache: 'no-store' }); + if (!r.ok) return true; + const j = await r.json().catch(() => null); + return !j || j.status === 'ERROR' || !j.callback; + } catch { return false; } // unreachable is not dead + } function profileScreen() { const pk = ui.profilePk; @@ -12808,7 +12862,12 @@ export function messagesFeature(ctx) { // authoritative locally, a deep link carries the address in the URL // (seeded as `addr`), and the light cache remembers it from last time. const lp = profileOf(pk) || {}; - const rawNip05 = (full && full.nip05) || (mine && myAddr) || lp.nip05 || lp.addr || null; + // Our own page never shows a name at our domain that the registrar says + // is no longer ours (see healStaleAddress): the claimed address is the + // truth here, and the kind 0 is brought in line in the background. + const stale = (v) => mine && myAddr && typeof v === 'string' && v !== myAddr && v.split('@')[1] === myAddr.split('@')[1] && !/^_@/.test(v) ? v : null; + if (full) { const olds = [...new Set([stale(full.lud16), stale(full.nip05)].filter(Boolean))]; if (olds.length) healStaleAddress(olds, myAddr); } + const rawNip05 = (full && (stale(full.nip05) ? myAddr : full.nip05)) || (mine && myAddr) || lp.nip05 || lp.addr || null; const nip05 = rawNip05 ? String(rawNip05).replace(/^_@/, '') : null; const lud16 = (full && full.lud16 ? String(full.lud16) : null) || lp.lud16 || null; const showLud = lud16 && !/^npub1/i.test(lud16) && lud16 !== nip05; @@ -18255,13 +18314,7 @@ export function messagesFeature(ctx) { // A payment-address rename released the old name: repoint the kind 0's // lud16 and nip05 — but only where they pointed at the released address // (or were empty). Deliberately different values are not ours to touch. - addressRenamed(oldAddr, newAddr) { - const follows = (k) => (base) => !base[k] || base[k] === oldAddr; - return publishProfileFields({ lud16: newAddr, nip05: newAddr }, { - onlyWhen: (base) => ['lud16', 'nip05'].some((k) => follows(k)(base) && base[k] !== newAddr), - fieldWhen: { lud16: follows('lud16'), nip05: follows('nip05') }, - }).catch(() => {}); - }, + addressRenamed(oldAddr, newAddr) { return repointAddress(oldAddr, newAddr).catch(() => {}); }, // opens the profile, unless the caller has a step to take first (the // Accounts page switches to that identity before showing its profile) profileChip(pk, size, onOpen) { |
