From 84628442c8c4f2cf30e79f84fbbcc872ea8fe818 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 10 Sep 2026 11:00:48 -0700 Subject: names: a migration grant unlocks a reserved legacy name. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrating the coinos.io house account `coinos` swept its balance fine, then every claim bounced with "name is reserved": the reserved-word check ran before the migration grant was consulted. A reserved word that is also a real coinos.io account can only reach the registrar through a migration, whose grant is the old account's authenticated say-so — so the grant now clears the reserved check too. Reserved names without a grant stay reserved. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE --- names/server.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/names/server.js b/names/server.js index 971108c..ca8ff3c 100644 --- a/names/server.js +++ b/names/server.js @@ -1540,7 +1540,16 @@ Bun.serve({ const domain = String(claim.domain || 'halwallet.app').toLowerCase(); if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400); if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400); - if (RESERVED.has(name)) return json({ error: 'name is reserved' }, 400); + // A migration grant (the legacy account's authenticated session told + // us where the name is going) trumps BOTH the reserved list and the + // legacy reservation: a reserved word that is also a real coinos.io + // account (the house account `coinos` itself) can only ever arrive + // here through a migration, and the identity check below can never + // pass for a v3 wallet key. Reserved without a grant stays reserved. + const grant = (state.migrations || {})[`${name}@coinos.io`]; + const granted = domain === 'coinos.io' && grant + && (grant.pubkey === auth.pubkey || (grant.manager && grant.manager === auth.pubkey)); + if (RESERVED.has(name) && !granted) return json({ error: 'name is reserved' }, 400); if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400); const key = `${name}@${domain}`; @@ -1549,12 +1558,6 @@ Bun.serve({ if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) { return json({ error: 'name is taken' }, 409); } - // A migration grant (the legacy account's authenticated session told - // us where the name is going) trumps the legacy reservation — the - // identity check below it can never pass for a v3 wallet key. - const grant = (state.migrations || {})[`${name}@coinos.io`]; - const granted = domain === 'coinos.io' && grant - && (grant.pubkey === auth.pubkey || (grant.manager && grant.manager === auth.pubkey)); if (!existing && !granted && await takenByCoinosUser(domain, name, auth.pubkey)) return json({ error: 'name is taken' }, 409); // an npub-shaped name belongs to that identity alone if (npubPrefixOwner(name)) { -- cgit v1.2.3