diff options
| author | Adam Soltys <[email protected]> | 2026-09-11 08:33:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-11 08:33:43 -0700 |
| commit | fb33e7de7de8c787ed8d906026f0e0e4270af33e (patch) | |
| tree | 2f4798a4d61c37ef1ff59d63cd045ae965365eda | |
| parent | 2a7b18005bb573e1aa54f50b1f87918cc1dcd92d (diff) | |
| download | coinosv3-fb33e7de7de8c787ed8d906026f0e0e4270af33e.tar.gz coinosv3-fb33e7de7de8c787ed8d906026f0e0e4270af33e.zip | |
Offer migration only for submitted unmigrated legacy names
| -rw-r--r-- | src/app.js | 3 | ||||
| -rw-r--r-- | src/features/messages.js | 14 | ||||
| -rw-r--r-- | src/features/names.js | 71 | ||||
| -rw-r--r-- | tools/name-migration-test.js | 99 |
4 files changed, 161 insertions, 26 deletions
@@ -3087,9 +3087,6 @@ function onboardScreen() { try { localStorage.removeItem(ONB_STEP_KEY); } catch {} render(); } }, t('onbNotNow')), - // Most people are new. The ones who aren't know it, and can say so here - // rather than everyone being asked first. - h('button', { class: 'linklike small', onClick: () => { ui.onbError = ''; o.step = 'legacy'; render(); } }, t('onbHaveCoinos')), ]); } if (o.step === 'avatar') { diff --git a/src/features/messages.js b/src/features/messages.js index a9da0c3..a01c063 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2609,6 +2609,7 @@ export function messagesFeature(ctx) { const id = await identity(); if (!id) { noIdToast(); return; } const e = ui.profEdit; + e.migrationName = null; ui.profSaving = true; render(); try { @@ -2649,6 +2650,7 @@ export function messagesFeature(ctx) { 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; @@ -2839,12 +2841,16 @@ export function messagesFeature(ctx) { type: 'text', style: 'flex:1;min-width:0', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.profEdit.uname, - onInput: (ev) => { ui.profEdit.uname = ev.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, ''); ev.target.value = ui.profEdit.uname; }, + onInput: (ev) => { + ui.profEdit.uname = ev.target.value.toLowerCase().replace(/[^a-z0-9._-]/g, ''); + ev.target.value = ui.profEdit.uname; + if (ui.profEdit.migrationName) { ui.profEdit.migrationName = null; render(); } + }, }), h('span', { class: 'muted', style: 'white-space:nowrap;padding:0 8px' }, '@' + myAddr.split('@')[1]))) : null, - // the migrate door, right where a coinos.io veteran is looking - // at the name they want to bring over (mainnet names only) - myAddr && myAddr.split('@')[1] === 'coinos.io' ? h('button', { + // Offered only for a submitted name confirmed as legacy-only. + ui.profEdit.migrationName && ui.profEdit.migrationName === ui.profEdit.uname + && myAddr && myAddr.split('@')[1] === 'coinos.io' ? h('button', { type: 'button', class: 'linklike small', style: 'align-self:flex-start', onClick: () => { location.href = `https://coinos.io/migrate?to=${encodeURIComponent(myAddr)}&back=${encodeURIComponent(location.origin + '/')}`; diff --git a/src/features/names.js b/src/features/names.js index 7a0f0de..f05ac09 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -138,6 +138,30 @@ export function namesFeature(ctx) { save({}); } + // Only an explicit, rejected name submission can offer migration. A legacy + // account still exists after moving to v3, and its `migrated` flag predates + // v3, so the registrar record is what tells us the name has already moved. + async function claimRequestedName(name, opts = {}) { + const domain = DOMAIN(); + try { return await claim(name, opts); } + catch (err) { + if (domain === 'coinos.io' && ['name is taken', 'name is reserved'].includes(err.message)) { + try { + const r = await fetch(`${REGISTRAR}/name/${encodeURIComponent(name)}?domain=${encodeURIComponent(domain)}`, + { signal: AbortSignal.timeout(8000), cache: 'no-store' }); + const record = r.ok ? await r.json() : null; + if (record?.name === name && record.domain === domain && record.reserved === true && !record.pubkey) { + const legacy = await fetch(`https://coinos.io/api/users/${encodeURIComponent(name)}`, + { signal: AbortSignal.timeout(8000), cache: 'no-store' }); + const user = legacy.ok ? await legacy.json() : null; + if (user?.username?.toLowerCase() === name) err.migrationName = name; + } + } catch {} // An unavailable lookup leaves the original claim error. + } + throw err; + } + } + // An imported seed (or a new browser) doesn't know its username — the // registrar does. Ask by the wallet's nostr pubkey and adopt the answer // before deciding whether anything needs claiming. @@ -398,25 +422,45 @@ export function namesFeature(ctx) { type: 'text', placeholder: t('namesPlaceholder'), style: 'flex:1' + (big ? ';font-size:18px' : ''), autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: ui.nameClaim || '', - onInput: (e) => { ui.nameClaim = e.target.value.toLowerCase().trim(); }, + onInput: (e) => { + ui.nameClaim = e.target.value.toLowerCase().trim(); + if (ui.nameClaimMigration) { ui.nameClaimMigration = null; render(); } + }, }), h('span', { class: 'muted', style: 'align-self:center' }, '@' + DOMAIN())), h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => { const name = (ui.nameClaim || '').toLowerCase().trim(); if (!name) return; - ui.busy = true; ui.nameClaimError = null; render(); + ui.busy = true; ui.nameClaimError = null; ui.nameClaimMigration = null; render(); try { // Sign with the login identity when one can be (re)attached: the // registrar lets a migrated coinos user's own identity take their // name back, which the wallet key alone could never prove. const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null); - await claim(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); + await claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); ui.nameClaim = ''; ui.nameEditOpen = false; toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` })); - } catch (e) { ui.nameClaimError = e.message; } + } catch (e) { + ui.nameClaimError = e.message; + if (ui.nameClaim === name) ui.nameClaimMigration = e.migrationName || null; + } ui.busy = false; render(); - } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim'))); + } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')), + ui.nameClaimMigration && ui.nameClaimMigration === ui.nameClaim && DOMAIN() === 'coinos.io' + ? h('button', { + type: 'button', class: 'linklike small', + onClick: () => { + if (ui.onb?.step === 'spend') { + ui.onbError = ''; ui.onb.step = 'legacy'; render(); + } else { + const st = load(); + if (!st.name) return; + const addr = `${st.name}@${st.domain || DOMAIN()}`; + location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`; + } + }, + }, t('onbHaveCoinos')) : null); } // Settings: one card per concern, all laid out (no collapsed sections — @@ -796,7 +840,7 @@ export function namesFeature(ctx) { checked = false; lastError = null; // The claim field is a draft for THIS account — a name typed before a // logout used to greet the next account that opened on this device. - ui.nameClaim = null; ui.nameClaimError = null; suggestedFor = null; + ui.nameClaim = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = null; refresh(); }, // The pencil by the address opens this: choosing a name gets a page of @@ -811,18 +855,7 @@ export function namesFeature(ctx) { h('h3', {}, t('namesCustom')), addr ? h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr) : null, h('p', { class: 'small muted', style: 'margin:0' }, t('namesCustomHow')), - claimForm(true), - // The same door the onboarding wizard offers — a coinos.io veteran - // who skipped it there shouldn't have to make a new account to find - // it again. Round trip: coinos.io sweeps + releases, the ?migrated= - // return claims the name into this wallet. Mainnet only — staging - // names have no legacy counterpart. - addr && DOMAIN() === 'coinos.io' ? h('button', { - class: 'linklike small', style: 'align-self:center;margin-top:6px', - onClick: () => { - location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`; - }, - }, t('onbHaveCoinos')) : null), + claimForm(true)), h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.nameEditOpen = null; ui.nameClaimError = null; }) }, t('back'))); }, namesAdoptIdentity(signer, npub) { return adoptIdentity(signer, npub); }, @@ -869,7 +902,7 @@ export function namesFeature(ctx) { // the onboarding wizard renders the same claim form on its username step namesClaimForm() { return claimForm(true); }, // claim a specific name (the migration flow, after coinos.io released it) - namesClaimName(name, opts) { return claim(String(name).toLowerCase(), opts || {}); }, + namesClaimName(name, opts) { return claimRequestedName(String(name).toLowerCase(), opts || {}); }, stop() { clearTimeout(retryTimer); clearTimeout(deadline); deadline = null; }, receiveModes() { if (!available()) return []; diff --git a/tools/name-migration-test.js b/tools/name-migration-test.js new file mode 100644 index 0000000..0c1e817 --- /dev/null +++ b/tools/name-migration-test.js @@ -0,0 +1,99 @@ +// Exercise the real username submission handlers with mocked registrar and +// legacy responses. No names are registered and no profiles are published. +// Run: bun tools/name-migration-test.js +import assert from 'node:assert/strict'; +import { namesFeature } from '../src/features/names.js'; + +const storage = new Map(); +globalThis.localStorage = { + getItem: k => storage.get(k) ?? null, + setItem: (k, v) => storage.set(k, String(v)), +}; +const originalFetch = globalThis.fetch; +const h = (tag, props, ...children) => ({ tag, props, children: children.flat().filter(x => x != null) }); +const nodes = n => typeof n === 'object' ? [n, ...n.children.flatMap(nodes)] : []; +const text = n => typeof n === 'object' ? n.children.map(text).join('') : String(n); +const migration = tree => nodes(tree).find(n => n.tag === 'button' && /Migrate an existing/.test(text(n))); + +function setup({ error = 'name is taken', record, user, unavailable, network = 'mainnet', pending } = {}) { + storage.set('btc-wallet-network', network); + const ui = { nameClaim: 'legacy', screen: 'wallet', nameEditOpen: true }; + const calls = []; + const state = { name: 'npub1test', domain: network === 'mainnet' ? 'coinos.io' : 'staging.coinos.io' }; + globalThis.fetch = async (url, opts = {}) => { + calls.push(String(url)); + if (url.endsWith('/register')) { + if (opts.method === 'DELETE') return Response.json({}); + return Response.json(error ? { error } : {}, { status: error ? 409 : 200 }); + } + if (url.includes('/name/')) { + if (pending) await pending; + if (unavailable) throw new Error('offline'); + return Response.json(record ?? { name: 'legacy', domain: 'coinos.io', taken: true, reserved: true }); + } + if (url.includes('/api/users/')) return user ? Response.json(user) : new Response('', { status: 404 }); + throw new Error('unexpected request: ' + url); + }; + const feature = namesFeature({ + h, ui, render() {}, toast() {}, brandHeader: () => null, + wallet: { + nostrPubkey: () => 'a'.repeat(64), nostrSign: async e => e, + loadFeatureState: () => state, saveFeatureState() {}, + }, + hook: name => name === 'arkStaticAddress' ? 'ark-test-address' : null, + }); + const form = () => feature.namesClaimForm(); + const submit = () => nodes(form()).find(n => n.tag === 'button' && n.props.class === 'btn-primary btn-block').props.onClick(); + return { ui, calls, feature, form, submit }; +} + +try { + let s = setup({ user: { username: 'legacy', migrated: true } }); + assert.equal(migration(s.form()), undefined); + assert.equal(migration(s.feature.screenView()), undefined); + assert.equal(s.calls.length, 0, 'opening the form does not check migration'); + await s.submit(); + assert.ok(migration(s.form()), 'legacy-only name offers migration after rejection'); + s.ui.onb = { step: 'spend' }; + migration(s.form()).props.onClick(); + assert.equal(s.ui.onb.step, 'legacy', 'onboarding still reaches the migration instructions'); + nodes(s.form()).find(n => n.tag === 'input').props.onInput({ target: { value: 'different' } }); + assert.equal(migration(s.form()), undefined, 'editing the name removes the offer'); + console.log('✓ migration is offered only after submitting a confirmed legacy-only name'); + + s = setup({ error: null }); + await s.submit(); + assert.equal(migration(s.form()), undefined); + assert.ok(s.calls.every(url => url.endsWith('/register')), 'successful claims skip migration lookups'); + console.log('✓ free names claim normally without a migration prompt'); + + s = setup({ record: { name: 'legacy', domain: 'coinos.io', taken: true, pubkey: 'b'.repeat(64) }, user: { username: 'legacy' } }); + await s.submit(); + assert.equal(migration(s.form()), undefined); + assert.ok(!s.calls.some(url => url.includes('/api/users/')), 'already registered names never offer migration'); + console.log('✓ migrated and other v3 names show the claim error without migration'); + + for (const options of [{ error: 'name is reserved' }, { unavailable: true }, { user: { username: 'someoneelse' } }, + { record: {} }, { network: 'mutinynet', user: { username: 'legacy' } }, { error: 'signing timed out', user: { username: 'legacy' } }]) { + s = setup(options); + await s.submit(); + assert.equal(migration(s.form()), undefined); + } + console.log('✓ reserved words, failed lookups, staging, and unrelated errors do not offer migration'); + + let release; + s = setup({ user: { username: 'legacy' }, pending: new Promise(r => { release = r; }) }); + const submission = s.submit(); + s.ui.nameClaim = 'different'; + release(); + await submission; + assert.equal(migration(s.form()), undefined, 'a late result cannot offer migration for a new draft'); + console.log('✓ editing during submission cannot leave a stale migration offer'); + + s = setup({ error: 'name is reserved', user: { username: 'legacy', migrated: true } }); + await assert.rejects(s.feature.namesClaimName('legacy', { quietProfile: true }), err => err.migrationName === 'legacy'); + console.log('✓ profile saves receive the same confirmed migration result'); +} finally { + globalThis.fetch = originalFetch; +} +process.exit(0); |
