fix(names): owning a name is not yet the right to spend it - #61
Merged
Merged
Conversation
DimazzzZ
added this pull request to stack #63
September 21, 2026 15:16
DimazzzZ
force-pushed
the
stack/f-ownership-requires-registration
branch
from
September 21, 2026 16:32
94a92d7 to
05dc1ea
Compare
DimazzzZ
force-pushed
the
stack/f-ownership-requires-registration
branch
5 times, most recently
from
September 21, 2026 16:59
7a0836d to
13a1a81
Compare
`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
force-pushed
the
stack/f-ownership-requires-registration
branch
from
September 21, 2026 17:06
13a1a81 to
fe1d718
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 6/7, on top of #60.
During a reveal phase
getnameinfoalready 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.