Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cabf316
fix(sign): a reveal is not a name you can sign for
DimazzzZ Sep 20, 2026
3cae038
feat(names): report whether a name is actually registered
DimazzzZ Sep 20, 2026
3990522
fix(names): the two transfer capabilities were wrong in opposite ways
DimazzzZ Sep 20, 2026
ab79c00
feat(names): decide the modal's sections in one pure place
DimazzzZ Sep 20, 2026
0575860
fix(name-modal): show the sections this stage actually has
DimazzzZ Sep 20, 2026
8d44e19
docs: record the lifecycle section map, and flip the Sign message rule
DimazzzZ Sep 20, 2026
ed0d32e
fix(names): keep a test-only covenant import out of the lib build
DimazzzZ Sep 20, 2026
6e71340
fix(names): close what the review of this change found
DimazzzZ Sep 20, 2026
04f2865
fix(names): a stranded lockup is not something left to reveal
DimazzzZ Sep 20, 2026
bf140ef
fix(names): say what the name needs, and stop implying DNS is required
DimazzzZ Sep 20, 2026
6dc389c
fix(name-modal): stop the Register step reading as three broken controls
DimazzzZ Sep 20, 2026
a332ad1
fix(names): redeeming your own losing bids is not losing
DimazzzZ Sep 21, 2026
224b26f
fix(names): broadcasting your own register is not losing the name
DimazzzZ Sep 21, 2026
1fa0680
refactor(names): give ownership one place to be decided
DimazzzZ Sep 21, 2026
72d28a3
test(node): two flakes in the hsd tests, both in the harness
DimazzzZ Sep 21, 2026
a74ab34
docs: write down the two ways a test can pass alone and fail in the s…
DimazzzZ Sep 21, 2026
fe1d718
docs: changelog for the registration gate and the modal's section map
DimazzzZ Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@
- **Remote-node onboarding** — the first-run flow now opens with a "How do you want to connect?" step offering three choices: **Local full node** (default; start hsd on this device), **Remote node** (point at an existing hsd RPC, with a "Test connection" button that probes the node before you commit), and **SPV** (lightweight headers-only, read-only). Choosing a source persists `chain_source` + `node_mode` (plus `node_rpc_url` / API key for Remote node) up front so a new user reaches a working read+send wallet without waiting for a full local sync. Your recovery phrase never leaves the device — remote/SPV is a privacy/trust tradeoff, not custody. New Tauri command `check_node_connection` validates a candidate RPC (reachable / height / synced, and — once a wallet profile exists — whether the node's network matches the wallet's; a cross-network node, e.g. testnet-for-mainnet, is flagged with an amber warning under the "Connected" line and is not treated as a usable node) without persisting anything, honoring the existing plaintext-key / non-loopback transport guard. Settings' Chain source selector now offers the same four modes (local full / SPV / remote / explorer) and replaces the separate Node mode dropdown; Settings also gained the same "Test connection" affordance and an "Allow sending via remote node" toggle (`allow_remote_broadcast`, off by default) — the toggle appears both on the onboarding Remote step and in Settings.

### Fixed
- **Reveal is no longer offered on a name with nothing left to reveal.** A lockup stranded in an auction that lapsed is an unspent BID coin, and the Reveal button was gated on holding one of those anywhere in the wallet rather than in the auction being looked at. So on a fully revealed name the button stayed live for good and failed every time with "no unspent bid coin". It now reads the same set the reveal transaction is built from, so the button and the builder cannot disagree.
- **Registering no longer looks as though DNS records are required.** The Register step put a record editor in front of the user and said nothing about it. Records are optional — Handshake accepts an empty resource, and the wallet was already sending one when the editor was untouched — so the panel now says registering claims the name and records can follow with Update, and keeps the editor behind "Add DNS records now (optional)". Register also stopped appearing twice, live in both the guided step and the records section, and the manual auction actions now show only what the stage allows, each with a line saying what pressing it does — Redeem names the amount it reclaims.
- **The name status no longer contradicts the modal it opens.** The Owned Names table printed the auction phase while the modal printed the task, so a row reading "Closed" opened a modal headed "Won — Register Now". The table now shows the same summary the auctions list and the modal do, and defers to what is in flight when a transaction of yours is waiting for a block. The phase was nearly a constant down that column anyway: every name you own has a closed auction.
- **Reclaiming your own losing bids is no longer described as losing.** Outbidding yourself and winning leaves you owning the name and holding your own losing reveals — the ordinary outcome of bidding more than once. The wallet greeted it with a red "Lost — Redeem Now" on a name just registered. The state was right; only its description was written for the other way into it.
- **Broadcasting your own transaction no longer reads 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 or transfer 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. Ownership now survives an unconfirmed spend of your own; acting on the name stays blocked until the block lands, which was the half that was already right.
- **The name modal now shows the sections the current stage actually has.** Its advanced area was a flat catalogue of every verb the wallet has, filtered by one flag — "does this wallet own the name?" — and that flag is true during REVEAL for a wallet leading its own auction, because Handshake reports the highest revealer as the owner long before anyone has won. So a name you had only bid on offered DNS records, Ownership and Sign message, unfolded the section by itself without a click, and put a green "Owned by this wallet" badge above it. Each section is now live, still ahead (one muted line saying what unlocks it — "DNS records — after you register this name"), or not there at all. Register still lives in the records section, so a just-won name keeps its one button. Signing moved inside Ownership, where proving ownership belongs. The auction buttons say what they are: a manual fallback for when the guided panel has fallen out of step with the chain. While a transaction waits for a block nothing is offered at all, and the advanced toggle only appears when something behind it can actually be acted on — which removes the empty menu during OPENING and behind a node that cannot write, where every button repeated the reason already on the banner above.
- **Signing a message for a name now requires the name to be registered.** The signing key was resolved from whatever the name's owner record pointed at, with no check on what kind of coin that was — so during the reveal phase the wallet happily signed with its own reveal coin and returned a well-formed proof of ownership for a name nobody had won. Pasted into a verification flow it resolves as false, with nothing to explain why.
- **Editing DNS records can no longer cancel a transfer by accident.** Handshake accepts an UPDATE on a name that is mid-transfer, and that UPDATE *is* how a transfer is cancelled — so "Update" under DNS records was a way to lose a transfer in flight while saying nothing about transfers. It is refused while a transfer is pending, and says so; Cancel transfer remains, under the name that means it. Its sibling was wrong the other way: Cancel transfer was live on every registered name and built an update that changes nothing and costs a fee. It now requires a transfer to cancel.
- **Update, Transfer, Renew and Revoke are no longer offered on a name you have not won yet.** While an auction is in its reveal phase the node already reports whoever holds the highest reveal as the name's owner, so a wallet leading its own auction looked like an owner and the modal enabled every ownership action. None of them can work: until REGISTER the owner coin is a REVEAL, and Handshake allows a REVEAL to become only a REGISTER or a REDEEM, so each would have been refused by the node. They now require the name to actually be registered, and say "the name is not registered yet" when it is not.
- **An Owned Names row is no longer clickable as a whole.** The row carried its own click handler on top of the buttons in its cells, so pressing a block height ran the cell's handler and then the row's as the click bubbled up — two dialogs stacked on each other, and ticking the select box opened the name dialog. The row's actions are its own controls: the name, the two block heights, and Manage. Keyboard selection is unchanged. The shared `DataTable` still offers an opt-in row click and now ignores clicks that came from a control handling them itself.
- **Each bid now shows the amount its own reveal disclosed.** Existing wallets re-scan once on upgrade (migration 031), because values recorded under the old rule may be sitting on the wrong bid and the scanner never revisits a block it has passed. When one transaction reveals several bids — which is what revealing a name you bid on more than once does — the bid list attached the wrong figure to each row: a 1 HNS bid could be shown as having revealed 3 HNS. The chain scanner paired each reveal with "the earliest bid not yet matched", a heuristic that is indistinguishable from the truth while a wallet holds one bid per name. Handshake actually pairs a name covenant with the coin spent at the same index, so a reveal names exactly one bid; the scanner now keys on that outpoint and there is no guessing left.
Expand Down
16 changes: 16 additions & 0 deletions docs/CODING_STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ Locally `cargo test` is enough; CI uses nextest for its two-lane split (see
produced, never on a forced one (see `cookie_vault.rs::flip_hex_digit`).
- When a helper is moved, its tests move with it. Never delete a test to make
a refactor compile.
- **Process-global state needs a serial key.** `std::env::set_var` /
`remove_var` change the variable for the whole process, and the harness runs
tests as threads of one process — so a test that swaps `HOME` races every
test that reads it, including indirectly. Put `#[serial(<key>)]`
(`serial_test`) on the writer **and on every reader**, sharing one key per
variable. Rust 2024 marks these functions `unsafe` for exactly this reason.
Cost of getting it wrong: `test_hsd_candidates_includes_home_paths` failed
about one full run in twenty and passed alone every time.
- **Wait for content, never for the file.** `fs::read_to_string(p).ok()` is
`Some("")` the instant a file exists, and a writer that redirects (`echo x >
f`) creates and truncates before it writes a byte. A poll loop keyed on
`Some(_)` therefore exits on the empty window and asserts against nothing.
Treat an empty read as "not ready" (see `node_lifecycle_tests::recorded_argv`).
- A test that passes alone and fails in the suite is a defect in the test, not
a reason to retry it. Find the shared state; the two rules above are the two
ways it has bitten so far.

## TypeScript ↔ Rust bridge

Expand Down
78 changes: 69 additions & 9 deletions docs/specs/2026-09-20-multiple-bids-per-name.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ showing a stale state the user reads as a bug.
is not the current one. Its only valid spend was a REVEAL inside that
auction's window, so it is unrecoverable; it is reported, never offered as
an action.
- **Registered** — the name's owner coin is at `COV_REGISTER` or later.
Distinct from **owned**: during REVEAL hsd reports the highest revealer as
the owner, so a wallet leading its own auction is "owned" while holding
nothing but a REVEAL coin. `nameIsRegistered` carries the distinction to the
UI.
- **Live / upcoming / absent** — the three states a modal section can be in
(R19). Live has at least one allowed action. Upcoming belongs to a later
stage and renders as one muted line. Absent cannot apply and renders
nothing.
- **Waiting for a block** — we broadcast a transaction and the chain has not
mined it. Not an error and not a phase: a state the UI names so the user
does not read the unchanged phase as a failure.
Expand Down Expand Up @@ -152,9 +161,28 @@ highest revealer as owner, and a REVEAL coin may become only a REGISTER or a
REDEEM — every one of those transactions would have been refused by the node.
The reason says which half is missing (`"the name is not registered yet"`).
`can_register` is untouched: it is the action that moves the coin to REGISTER.
*Enforced:* `commands/names.rs::build_name_action_capabilities`.

Signing a message for a name is the same claim without a transaction, and is
refused under the same rule. `get_name_coin` resolves whatever
`tracked_name_states.owner_txid` points at and filters on no covenant, so
during REVEAL it hands back our own REVEAL coin; the command signed it and
returned a well-formed proof of ownership that every verifier resolves as
false.
*Enforced:* `commands/names.rs::build_name_action_capabilities`,
`commands/tx.rs::sign_name_message`.
*Pinned:* `names::tests::ownership_actions_need_a_registered_name_not_just_ownership`,
`names::tests::ownership_actions_stay_available_once_registered`.
`names::tests::ownership_actions_stay_available_once_registered`,
`sign_name_message_tests::rejects_a_name_whose_owner_coin_is_still_a_reveal`.

**R11b — The two transfer capabilities answer for the transfer.** Update is
refused while a transfer is pending: hsd lets a TRANSFER coin go to UPDATE,
RENEW, FINALIZE or REVOKE, and that UPDATE branch *is* the cancel, so a button
labelled "edit your DNS records" was a way to lose a transfer in flight.
Cancel transfer requires a transfer to cancel — the condition `can_finalize`
has always carried — instead of building a no-op UPDATE that costs a fee.
*Enforced:* `commands/names.rs::build_name_action_capabilities`.
*Pinned:* `names::tests::update_is_refused_while_a_transfer_is_pending`,
`names::tests::cancel_transfer_is_refused_when_no_transfer_is_pending`.

**R12 — A name whose auction lapsed can be opened again.** Only an
*unconfirmed* OPEN coin counts as a pending OPEN. The OPEN output is a
Expand Down Expand Up @@ -224,6 +252,31 @@ came from a control handling them itself.
`wallet-view.test.tsx :: clicking a cell button in an Owned Names row opens only that button's dialog`,
`rowClick.test.ts`.

**R19 — The modal's sections are a map of the lifecycle, not a catalogue of
verbs.** Each of the three — manual auction actions, DNS records, ownership
(which now contains signing) — is **live** when something inside is allowed,
**upcoming** when it belongs to a later stage (one muted line naming what
unlocks it, no controls), or **absent** when it cannot apply. Four rules carry
the weight: Register lives in the records section, so that section opens
before the name is registered; a pending transfer closes it again (R11b),
read from the backend's `transferPending` rather than re-derived from the
phase; while a broadcast waits for a block every section is absent, since
offering alternatives then only invites a competing transaction; and the
advanced area — toggle and container both — appears only when at least one
section is live, so an upcoming line is shown beside a live section and never
as a menu whose whole content is "come back later". Every gate that read
`ownsName` — the section filter, auto-expand, the toggle and its label, the
read-only DNS suppression, and the "Owned by this wallet" badge — now reads
the stage.
*Enforced:* `src/lib/nameSections.ts::resolveSections`,
`src/components/NameActionsModal.tsx`,
`src/components/name-actions/UpcomingSection.tsx`,
capability fields `nameIsRegistered` and `transferPending`.
*Pinned:* `nameSections.test.ts` (the stage matrix),
`name-modal-sections.test.tsx`,
`name-actions-bid-gate.test.tsx :: offers no advanced section at all during OPENING, so no bid can be invited`,
`name-actions-gating.test.tsx :: states the reason once and offers no menu when the node can't write`.

## 4. Explicitly not enforced

- **A stranded lockup is not recoverable.** R10 reports it; nothing reclaims
Expand All @@ -240,8 +293,16 @@ came from a control handling them itself.
- **A commitment with no recorded auction is not attributed to one.** R3
counts it in the current auction deliberately. It is not proof the bid is
live.
- **`Sign message` is not gated on registration.** R11 covers the five
ownership *spends*; signing is offline and does not produce a transaction.
- **An upcoming section is not shown on its own.** R19 renders it as one muted
line naming what unlocks it, but only inside the advanced area, which needs
a live section to exist at all. On a name where nothing is actionable — a
reveal already sent, say — there is no menu and no line: a menu whose whole
content is "come back later" is the empty menu R19 exists to remove. It is
also not expandable, since expanding would reveal nothing.
- **The three states are not permissions.** A live section can still hold
buttons that are individually refused — an owner coin that has not synced,
a locked signer — each with its own reason. The section answers "does this
stage have this?", the capability answers "can you press it?".

## 5. Known gaps

Expand All @@ -251,11 +312,6 @@ came from a control handling them itself.
moved (the fee and the transaction itself are correct). Accepted for now:
it is a display figure on a self-spend, and every output returns to the
wallet.
- **Advanced sections render disabled rather than hidden.** On a name where
the whole section is unavailable — DNS records before REGISTER, say — the
buttons are shown disabled with their reason (R17) instead of the section
being hidden. Accepted: a visible reason teaches the phase order; an absent
section reads as a missing feature.
- **`DataTable` still carries an `onRowClick` prop with no caller.** Kept
because the guard in R18 is the thing worth keeping, and the next table that
wants a row click should get the guarded version.
Expand Down Expand Up @@ -289,13 +345,17 @@ came from a control handling them itself.
`ActionReasonBanner.tsx`.
- `src/lib/rowClick.ts`, `src/components/ui/DataTable.tsx`,
`src/components/WalletView.tsx`.
- `src/lib/nameSections.ts` — the stage matrix, and the only place that
decides which sections exist.
- `src/components/name-actions/UpcomingSection.tsx`.

**Tests**
- `src-tauri/src/tests/live_node_it.rs` — the regtest end-to-end passes, gated
on `HNS_IT_NODE_URL`. Run against a scratch node, never a chain you care
about: they mine.
- `src-tauri/src/tests/{chain_scan,read_cmd,names_action_context,name_capabilities,names_cmd,deadlines_cmd}_tests.rs`.
- `src/lib/auction.test.ts`, `src/lib/rowClick.test.ts`,
`src/lib/nameSections.test.ts`,
`src/components/__tests__/{wallet-view,name-acquisition,auction-positions,tooltip}.test.tsx`,
`src/components/name-actions/__tests__/name-bids-panel.test.tsx`.

Expand Down
Loading
Loading