Skip to content

fix(names): owning a name is not yet the right to spend it - #61

Merged
DimazzzZ merged 17 commits into
mainfrom
stack/f-ownership-requires-registration
Sep 21, 2026
Merged

DimazzzZ merged 17 commits into
mainfrom
stack/f-ownership-requires-registration

Conversation

@DimazzzZ

Copy link
Copy Markdown
Owner

Stack 6/7, on top of #60.

During a reveal phase getnameinfo already names whoever holds the highest reveal as the owner. A wallet leading its own auction therefore read as an owner, and everything gated on that flag believed it.

Five ownership actions were offered on a name nobody had won. Update, Transfer, Cancel transfer, Renew and Revoke. None could work: until REGISTER the owner coin is a REVEAL, and Handshake lets a REVEAL become only a REGISTER or a REDEEM, so each would have come back refused. They now need the name to actually be registered. Signing a message was the same claim without a transaction and was not gated at all — the wallet produced a well-formed proof of ownership for a name it had not won, which any verifier resolves as false.

The modal showed a catalogue of verbs, not the stage. Its advanced area was filtered by that one flag, so DNS records, Ownership and Sign message appeared on a name you had only bid on — and auto-expanded, without a click. Each section is now live, still ahead (one muted line naming what unlocks it), or absent. Register lives in the records section, so a just-won name keeps its one button; signing moved inside Ownership; the auction buttons say they are a manual fallback; and while a broadcast waits for a block nothing is offered at all. The decision moved into one pure function so the stage matrix is a table of unit tests rather than nine renders of a thousand-line modal.

Broadcasting your own transaction read as losing the name. hsd drops a coin from its unspent set as soon as a mempool transaction spends it, so the instant a register went out the wallet stopped finding the owner coin and concluded it did not own the name — which, on a closed auction still holding reveals, is the shape of a loss, complete with a red "Lost — Redeem Now". Ownership survives an unconfirmed spend of your own now; acting on the name stays blocked until the block lands, which was the half that was already right.

Also: reclaiming your own losing bids is no longer described as losing; Reveal is not offered on a name with nothing left to reveal; registering no longer looks as though DNS records are required; and the Register step stopped rendering three broken-looking controls.

Housekeeping rides along: two flaky hsd tests (both faults in the harness, not the product) and the two rules that caused them written into CODING_STANDARDS.md.

Rust 2555, frontend 888.

@github-actions github-actions Bot added domain: wallet Balances, send/receive, accounts, HD derivation, coin selection. domain: names Auction lifecycle, batch ops, watchlist, paid swaps, lost-bid recovery. labels Sep 21, 2026
@DimazzzZ DimazzzZ added type: bug Defect or unexpected behavior. domain: ui UI/UX: components, dialogs, tables, onboarding, updates. priority: high Impacts users; should ship soon. labels Sep 21, 2026
@DimazzzZ
DimazzzZ added this pull request to stack #63 September 21, 2026 15:16
@DimazzzZ
DimazzzZ force-pushed the stack/f-ownership-requires-registration branch from 94a92d7 to 05dc1ea Compare September 21, 2026 16:32
@github-actions github-actions Bot removed domain: ui UI/UX: components, dialogs, tables, onboarding, updates. labels Sep 21, 2026
@DimazzzZ
DimazzzZ force-pushed the stack/f-ownership-requires-registration branch 5 times, most recently from 7a0836d to 13a1a81 Compare September 21, 2026 16:59
Base automatically changed from stack/e-multi-bid-lifecycle to main September 21, 2026 17:06
`sign_name_message` resolves the signing key through `get_name_coin`,
which joins whatever `tracked_name_states.owner_txid` points at and
filters on no covenant at all. During REVEAL hsd already reports the
highest revealer as the owner, so for a wallet leading its own auction
that row points at its own REVEAL coin — and the command signed it,
returning a well-formed claim of ownership for a name nobody had won
yet. Any verifier resolving the name's real owner reads that claim as
false, with nothing to explain why.

It now requires the owner coin to be REGISTER or later, the same rule
the ownership capabilities use, and says "is not registered yet".

The happy-path fixture seeded `covenant_type = 4` — COV_REVEAL — so the
one test that proved signing works was proving it on a name that is not
owned. The seed now takes the covenant explicitly and defaults to
REGISTER, which is what a signable name actually looks like.
`ownsName` is not "this wallet controls the name": during REVEAL
`getnameinfo` names the highest revealer as the owner, so a wallet
merely leading its own auction reads as an owner while holding nothing
but a REVEAL coin. The capability gates already know the difference —
they compute it to decide Update, Transfer, Renew and Revoke — but the
frontend had no way to ask, and re-derived it from `ownsName`.

That wrong answer is what put DNS records, Ownership and Sign message on
screen for a name the wallet had not won, and auto-expanded the section
so it appeared without a click.

The backend now states it once, as `nameIsRegistered`, so there is one
answer to read rather than two to keep in step. The conservative
fallback reports false: with no node-synced owner coin nothing proves
the name is registered.
Update was offered during a pending transfer, and Cancel transfer was
offered when there was no transfer at all.

Update is the dangerous one. hsd lets a TRANSFER coin go to UPDATE,
RENEW, FINALIZE or REVOKE (`rules.verifyCovenants`), and that UPDATE
branch *is* how a transfer is cancelled — so the node accepts the
transaction and the pending transfer quietly disappears. A button
labelled "edit your DNS records" was a way to lose a transfer in flight,
with nothing on screen naming transfers at all. It is now refused while
a transfer is pending, and says so; cancelling stays available under its
own name, which is the button that means it.

Cancel transfer is the cheap one: `can_finalize` has always required
`transfer_has_items` and its sibling did not, so on an ordinary
registered name it was live and built an UPDATE that changes nothing and
costs a fee.

One fixture asserted both old rules while describing two states at once
— an owned name that was also mid-transfer. It is now the ordinary owned
name, and the pending-transfer state has its own case.
The name-actions modal ran two organising principles at once: a guided
panel that knows the stage, and a flat catalogue of every verb the
wallet has, filtered by `ownsName` alone. The catalogue had no idea what
stage the name was in, so it relied on disabled buttons to explain — and
its one filter is true during REVEAL for a wallet leading its own
auction, which is how DNS records, Ownership and Sign message reached
the screen on a name nobody had won.

`resolveSections` gives each section one of three states instead. Live
when something inside is allowed. Upcoming — one muted line naming what
unlocks it, no controls — when it belongs to a later stage. Absent when
it cannot apply at all. That keeps the phase order visible, which is why
the sections were left on screen in the first place, without leaving a
dead control per verb.

It is pure, so the stage matrix is a table of unit tests rather than
nine renders of a thousand-line modal. Three rules are worth naming:
Register lives in the records section, so that section opens before the
name is registered; a pending transfer closes it again, because hsd
accepts TRANSFER -> UPDATE and that transition is the cancel; and while
a broadcast waits for a block every section is absent, since offering
alternatives then only invites a competing transaction.
The advanced area now renders what `resolveSections` says exists, and
every gate that used to read `ownsName` reads the stage instead.

On a name the wallet is only leading — the case that prompted this —
DNS records, Ownership and Sign message are gone, replaced by one muted
line each saying they arrive after the name is registered. The section
was not even being opened by hand: auto-expand read the same flag, so it
unfolded itself. The green "Owned by this wallet" badge read it too, and
claimed ownership of a name still being auctioned.

Signing moves inside Ownership, where proving ownership belongs, and
needs the same registration the backend now requires of it. The auction
section says what it is — a manual fallback for when the guided panel
has fallen out of step — instead of standing there unexplained. While a
broadcast waits for a block nothing is offered at all.

One rule now decides the toggle: open it only when something behind it
can be acted on. That drops three phase special cases, and drops the
menu in two places it used to appear empty — during OPENING, whose only
entry was a disabled Open, and behind a node that cannot write, where
every button carried the same reason the banner above already gives.
Two tests asserted those menus; they now assert their absence.

The editable records section owns the DNS read, the freshness gate and
the one-shot seeding, so none of them can drift from what is on screen.
The spec said `Sign message` was deliberately not gated on registration,
on the grounds that signing is offline and builds no transaction. That
reasoning was wrong about what a signature over a name *is*: a claim to
own it. Until REGISTER there is nothing to claim, and the wallet was
emitting a well-formed one anyway. R11 now covers it, R11b covers the
two transfer capabilities, and R19 states the three-state section map.

Section 5 loses its accepted gap about sections rendering disabled. The
two options weighed there — show them disabled to teach the phase order,
or hide them and read as a missing feature — are both avoided by keeping
the heading and dropping the controls.
`COV_TRANSFER` is compared against only in the capability tests — the
code itself checks against COV_REGISTER — so importing it at module
level failed `clippy -D warnings` on the non-test build.
Five findings, the first two of them real bugs shipped by the commits
above.

`resolveSections` read `taskState === "transferPendingFinalize"` to
decide whether a transfer was in flight. That comes from the phase
string, while the `can_update` gate it is meant to mirror keys on
`transfer_has_items` — a second source of truth, and exactly the
re-derivation this whole change was about removing. Where the two
disagree the records section stood open over a button the node refuses.
The backend now reports `transfer_pending` and the UI reads it.

`nameIsRegistered` was optional on the TypeScript side "so existing
fixtures stay valid", which the bridge rule in CODING_STANDARDS forbids:
the Rust field is a plain `bool`, and adding a field means updating
every fixture that builds the object. Leaving it optional hid the real
casualty — `webqa-mock.ts` is not a fixture but the web-QA harness, and
it never set the flag, so in that mode every advanced section vanished
and Sign message never rendered. Both fields are now required and every
builder states them.

Auto-expand could leave the advanced container open while every section
had since gone absent — a broadcast going out does that — rendering an
empty bordered box. `anyLive` now guards the container, not just the
toggle.

The rest are hygiene: the two comments that still justified themselves
by an advanced toggle they no longer feed, and an UpcomingSection test
id built out of display copy, so rewording a heading renamed the id.

The spec overclaimed in one place, and is corrected rather than the code:
an upcoming section rides alongside a live one and is not shown on its
own, because a menu whose whole content is "come back later" is the
empty menu this change exists to remove.
Found on a live regtest wallet. `vmp3rt3` had all four of this auction's
bids revealed in one transaction, and Reveal was still offered — every
press failing with "no unspent bid coin for 'vmp3rt3' (sync first?)".

The refusal was right and the button was wrong. `can_reveal` gates on
`has_bid_coin`, which searched unspent BID coins by name hash across the
whole profile, while `build_reveal_draft` resolves them from the
commitments of the current auction. That wallet holds a lockup stranded
in an auction that lapsed at height 111, and a stranded BID coin can
never be revealed — a REVEAL is only valid while `start == ns.height`.
So it answered "yes, there is a bid coin" for an auction it has nothing
to do with, permanently, on a name with nothing left to do.

`has_bid_coin` now comes from the same set the draft builds its
transaction from, so the button and the builder cannot disagree. The
reason the lookup left the commitment's address in the first place still
holds — "the newest commitment" was never well defined — and is kept by
searching every commitment of this auction rather than one of them.

The stranded lockup is still reported; it is just no longer mistaken for
work outstanding.
Two things a live wallet made confusing at the same moment.

The Owned Names table said "Closed" for a name whose modal, opened from
that very row, was headed "Won — Register Now". Both were real data —
the auction phase and the task state — but one name described two ways
is a reader's problem, not a reader's mistake. The Auctions list and the
modal already agree through `taskSummaryFromCapabilities`; the table now
reads the same summary, and falls back to the raw phase only before the
capabilities land or on a watch-only profile that never fetches them.
The phase was nearly a constant down that column anyway: every name you
own has a closed auction. The three places that indexed the capability
batch now share one index, so they cannot drift.

The Register panel put a DNS record editor in front of the user and said
nothing about it, which reads as "records required to register". They
are not: hsd caps the resource size and accepts an empty one
(`rules.verifyCovenants`, REGISTER items[2]), and the wallet already
sends an empty resource when the editor is untouched. The panel now says
registering claims the name and records can follow with Update, and
keeps the editor behind "Add DNS records now (optional)" so the ordinary
path is one button.
Three things a live wallet showed at once on a just-won name.

"Add DNS records now (optional)" sat on the same line as Register. The
disclosure was a bare inline button next to an inline-flex one, and
`space-y-*` only separates block children, so the two collapsed into a
line that reads as one damaged control row. It gets a block of its own.

Register appeared twice, live in both places: once as the guided step
and again inside the records section. The guided panel owns the action
whenever it is the step the name is on, so the records section stops
repeating it — the same de-duplication the bid form needed in BIDDING.

"Redeem" was offered with nothing said about it, next to a greyed Open
and a greyed Reveal. A covenant name alone reads as something the user
failed to understand, and here it was the wallet holding money for them:
three losing reveals of their own. The manual section now renders only
the actions this stage allows, each with a line saying what pressing it
does, and Redeem names the amount — which the backend now reports as
`redeemableRevealCount` / `redeemableValueDoos` rather than leaving the
figure in the action context where the UI could not reach it.
Outbidding yourself and winning lands on `lostNeedsRedeem` while the
wallet owns the name: the reveals left to reclaim are its own losing
bids. That is the ordinary outcome of placing several bids on one name —
the case this branch exists for — and the wallet greeted it with "Lost —
Redeem Now" and a red "Your bid lost. Redeem your reveal coin to reclaim
the funds." on a name the user had just registered.

The state is right; only its description was written for the other way
in. When the wallet owns the name the label reads "Reclaim Your Lockup",
the badge drops out of the error tone, and the reason says what is
actually true: you own this name, these are your own losing bids on it.
A genuine loss is unchanged, and pinned so this branch cannot swallow
the case it was carved out of.
Reported live: the name modal's header turned red and read "Lost —
Redeem Now" on a name the user had just paid to register, while the
panel directly below it said the register was broadcast and waiting to
be mined. One modal, two contradictory claims.

The chain is the reason, not a mistake in reading it. hsd drops a coin
from its unspent set the moment a MEMPOOL transaction spends it, so the
instant the register went out the winning reveal stopped being reported,
`sync` marked it spent, and `get_name_coin` — which returns unspent
coins only — found nothing. `owns_name` is built from that, and CLOSED
plus not-owned plus holding reveals is exactly the shape of a lost
auction. We do still own it: if the register never confirms, the coin is
simply unspent again.

Which transaction spent it is not knowable here. `sync` marks a coin
spent by diffing the node's live coin set and writes the sentinel
'spent', never a txid — a first fix keyed on that link passed its test
and did nothing against the reporting wallet's own data, because the
link does not exist in production. What the wallet does know is that it
has an unconfirmed transaction of its own for this name, and which
action it performs; only the ones that spend the owner coin count, so a
redeem in flight still says nothing about ownership. `spend_locked` is
untouched, which is the half that was right: we own it and cannot act on
it until the block lands.

Finding along the way: more than one transaction can be in flight for a
name — a register and a redeem spend different coins — and the single
pending action was picked by `created_at`, which has second resolution.
The ownership question now looks at all of them.

The header had a fault of its own and keeps its own fix: it rendered the
task label whatever was in flight, so it urged an action the wallet had
already sent. It now shows what the auctions list has always shown
there.
"Do we own this name?" and "may we build a transaction for it?" look
like one question and are not, and the gap between them is where the
just-registered name came to be reported as lost. Both call sites of
`get_name_action_capabilities` spelled out the same three lines to
answer both — which is why the fix for that bug had to be written twice,
and why the only proof it worked was a throwaway probe against a copy of
a user's wallet.

`derive_name_ownership` now answers both, once, from the evidence:
a node-synced unspent owner coin, our own owner-spend still in flight,
or an owner address the profile recognises. Spending unlocks on the
first of those alone — explorer evidence classifies and never unlocks,
and a spend already in flight must not be raced.

Five cases pinned, including the two the old shape could not reach from
a test: ownership surviving an unconfirmed owner spend, and explorer
evidence granting the claim without the capability.
Caught while running the suite for unrelated work: about one run in
twenty, one of two node tests failed, and both passed on their own.
Measured on a loop over the `hsd` filter — roughly 5% red across 20
runs, unchanged by thread count from 1 to 64.

The first announced "hsd was NOT told to run on regtest via --network=;
argv was:" with nothing after the colon, which is the whole story: the
fake hsd records with `echo "$@" > log`, and the shell creates and
truncates that file before echo writes a byte into it. `recorded_argv`
returned `Some("")` for that window, which satisfied the caller's poll
loop on its first tick and left it asserting against an empty argv. An
empty read is not a recording, and now reads as one.

The second was `hsd_candidates` not containing paths under $HOME. One
test in the file swaps the process-global HOME to a temporary tree to
exercise the nvm discovery branch and puts it back afterwards, and
`std::env::set_var` is process-global while the harness runs tests as
threads. The reader takes its candidates and its HOME in two separate
statements, so a swap landing between them compares paths built from one
HOME against the other. Every test in the file that reads HOME — through
`hsd_candidates` or `find_hsd_binary` — now shares a serial key with the
one that writes it.

Neither is a product fault: hsd really is told its network, and the
candidate list really does include the home paths. Verified by the same
loop, 0 red in 400 runs where ~20 were expected, plus three clean full
passes.
…uite

Both cost a real debugging session this week, and neither is guessable
from the failure message: one announced that hsd was never told its
network, the other that the candidate list was missing paths under
$HOME. Both were true statements about a harness reading state it did
not own.

The rules are stated with the cost attached — one full run in twenty —
because "use a serial key" reads as ceremony until you know what it buys.
@DimazzzZ
DimazzzZ force-pushed the stack/f-ownership-requires-registration branch from 13a1a81 to fe1d718 Compare September 21, 2026 17:06
@DimazzzZ DimazzzZ self-assigned this Sep 21, 2026
@DimazzzZ
DimazzzZ merged commit 5f24222 into main Sep 21, 2026
7 checks passed
@DimazzzZ
DimazzzZ deleted the stack/f-ownership-requires-registration branch September 21, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: names Auction lifecycle, batch ops, watchlist, paid swaps, lost-bid recovery. domain: wallet Balances, send/receive, accounts, HD derivation, coin selection. priority: high Impacts users; should ship soon. type: bug Defect or unexpected behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant