From 843feca4dfdd42e15da56b57efb4a95d1bdf404a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 9 Oct 2026 00:58:46 -0700 Subject: Change username: a name this wallet's key claimed before a login identity was attached no longer reads as taken — the identity-signed claim is refused, so retry signed by the wallet key that owns the record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/features/names.js | 11 +++++++++++ tools/name-migration-test.js | 25 +++++++++++++++++++++---- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/features/names.js b/src/features/names.js index 43d4895..ea94d2a 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -146,6 +146,17 @@ export function namesFeature(ctx) { const domain = DOMAIN(); try { return await claim(name, opts); } catch (err) { + // Signed by the login identity, a name this WALLET's key claimed + // earlier (before the identity was attached) reads as someone else's — + // the record names the wallet key as owner and no manager. The wallet + // key may always update its own record, so retry with it. + if (opts.signer && err.message === 'name is taken') { + const mine = await fetch(`${REGISTRAR}/name/${encodeURIComponent(name)}?domain=${encodeURIComponent(domain)}`, + { signal: AbortSignal.timeout(8000), cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null); + const pk = wallet.nostrPubkey && wallet.nostrPubkey(); + if (pk && mine?.taken && (mine.pubkey === pk || mine.manager === pk)) + return claim(name, { ...opts, signer: undefined, manager: undefined }); + } 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)}`, diff --git a/tools/name-migration-test.js b/tools/name-migration-test.js index 0c1e817..cbc087d 100644 --- a/tools/name-migration-test.js +++ b/tools/name-migration-test.js @@ -15,7 +15,7 @@ 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 } = {}) { +function setup({ error = 'name is taken', record, user, unavailable, network = 'mainnet', pending, identity } = {}) { storage.set('btc-wallet-network', network); const ui = { nameClaim: 'legacy', screen: 'wallet', nameEditOpen: true }; const calls = []; @@ -24,6 +24,9 @@ function setup({ error = 'name is taken', record, user, unavailable, network = ' calls.push(String(url)); if (url.endsWith('/register')) { if (opts.method === 'DELETE') return Response.json({}); + const by = JSON.parse(atob(opts.headers.authorization.slice(6))).pubkey; + calls.push('register-by:' + by); + if (typeof error === 'function') { const e = error(by); return Response.json(e ? { error: e } : {}, { status: e ? 409 : 200 }); } return Response.json(error ? { error } : {}, { status: error ? 409 : 200 }); } if (url.includes('/name/')) { @@ -37,10 +40,11 @@ function setup({ error = 'name is taken', record, user, unavailable, network = ' const feature = namesFeature({ h, ui, render() {}, toast() {}, brandHeader: () => null, wallet: { - nostrPubkey: () => 'a'.repeat(64), nostrSign: async e => e, + nostrPubkey: () => 'a'.repeat(64), nostrSign: async e => ({ ...e, pubkey: 'a'.repeat(64) }), loadFeatureState: () => state, saveFeatureState() {}, }, - hook: name => name === 'arkStaticAddress' ? 'ark-test-address' : null, + hook: name => name === 'arkStaticAddress' ? 'ark-test-address' + : name === 'nostrLoginResume' && identity ? { pubkey: identity, signEvent: async e => ({ ...e, pubkey: identity }) } : null, }); const form = () => feature.namesClaimForm(); const submit = () => nodes(form()).find(n => n.tag === 'button' && n.props.class === 'btn-primary btn-block').props.onClick(); @@ -64,7 +68,7 @@ try { 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'); + assert.ok(s.calls.filter(c => !c.startsWith('register-by:')).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' } }); @@ -93,6 +97,19 @@ try { 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'); + + // A name the wallet key claimed before a login identity was attached: the + // identity-signed claim is refused, the wallet key's retry goes through. + s = setup({ identity: 'c'.repeat(64), error: by => (by === 'a'.repeat(64) ? null : 'name is taken'), + record: { name: 'legacy', domain: 'coinos.io', taken: true, pubkey: 'a'.repeat(64), manager: null } }); + await s.submit(); + assert.equal(s.ui.nameClaimError, null); + assert.deepEqual(s.calls.filter(c => c.startsWith('register-by:')), ['register-by:' + 'c'.repeat(64), 'register-by:' + 'a'.repeat(64)]); + s = setup({ identity: 'c'.repeat(64), record: { name: 'legacy', domain: 'coinos.io', taken: true, pubkey: 'b'.repeat(64) } }); + await s.submit(); + assert.equal(s.ui.nameClaimError, 'name is taken'); + assert.equal(s.calls.filter(c => c.startsWith('register-by:')).length, 1, 'someone else\'s name is not retried'); + console.log('✓ the wallet key reclaims its own name when the login identity is refused'); } finally { globalThis.fetch = originalFetch; } -- cgit v1.2.3