aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-11 08:33:43 -0700
committerAdam Soltys <[email protected]>2026-09-11 08:33:43 -0700
commitfb33e7de7de8c787ed8d906026f0e0e4270af33e (patch)
tree2f4798a4d61c37ef1ff59d63cd045ae965365eda
parent2a7b18005bb573e1aa54f50b1f87918cc1dcd92d (diff)
downloadcoinosv3-fb33e7de7de8c787ed8d906026f0e0e4270af33e.tar.gz
coinosv3-fb33e7de7de8c787ed8d906026f0e0e4270af33e.zip
Offer migration only for submitted unmigrated legacy names
-rw-r--r--src/app.js3
-rw-r--r--src/features/messages.js14
-rw-r--r--src/features/names.js71
-rw-r--r--tools/name-migration-test.js99
4 files changed, 161 insertions, 26 deletions
diff --git a/src/app.js b/src/app.js
index 392f4a5..e53a7d7 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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);