diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6183b4..2178f31c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md index ff0a15a4..a307f079 100644 --- a/docs/CODING_STANDARDS.md +++ b/docs/CODING_STANDARDS.md @@ -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()]` + (`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 diff --git a/docs/specs/2026-09-20-multiple-bids-per-name.md b/docs/specs/2026-09-20-multiple-bids-per-name.md index 3f912df4..375a6eb2 100644 --- a/docs/specs/2026-09-20-multiple-bids-per-name.md +++ b/docs/specs/2026-09-20-multiple-bids-per-name.md @@ -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. @@ -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 @@ -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 @@ -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 @@ -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. @@ -289,6 +345,9 @@ 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 @@ -296,6 +355,7 @@ came from a control handling them itself. 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`. diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index cd2e1b84..338bf584 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -348,6 +348,20 @@ pub struct NameActionCapabilities { pub phase: String, pub task_state: AuctionTaskState, pub owns_name: bool, + /// Whether the name is actually REGISTERED — an owner coin at + /// `COV_REGISTER` or later. `owns_name` is NOT this: during REVEAL + /// `getnameinfo` already names the highest revealer as the owner, so a + /// wallet merely leading its own auction owns a REVEAL coin and nothing + /// more. The same value gates `can_update` and its siblings; it is + /// reported so the UI decides which sections exist from the backend's + /// answer instead of re-deriving a wrong one from `owns_name`. + pub name_is_registered: bool, + /// Whether a TRANSFER is in flight for this name — the same + /// `transfer_has_items` the gates use. Reported because the UI has to + /// close the records section for exactly the names `can_update` refuses, + /// and the phase string is a different question: it and the items can + /// disagree, and then the section and the button inside it disagree too. + pub transfer_pending: bool, pub has_bid_commitment: bool, pub has_bid_coin: bool, pub has_reveal_coin: bool, @@ -407,6 +421,12 @@ pub struct NameActionCapabilities { /// bids panel now that it is scoped to the current auction. pub stranded_bid_count: i64, pub stranded_lockup_doos: i64, + /// Losing reveals on this name that a REDEEM would reclaim, and what they + /// are worth. Reported so the button can say what it does: "Redeem" alone + /// is a covenant name, not an explanation, and this is money the wallet is + /// holding for the user. + pub redeemable_reveal_count: i64, + pub redeemable_value_doos: i64, } /// Context gathered from the DB for a name action evaluation. @@ -452,6 +472,12 @@ pub(crate) struct NameActionContext { /// losing bids this wallet can still redeem. Several, when it outbid /// itself. pub redeemable_reveal_count: i64, + /// What those reveals are worth together — the sum a REDEEM reclaims. + pub redeemable_value_doos: i64, + /// The owner coin is spent, but only by a transaction this wallet + /// broadcast and the chain has not mined. We still own the name — we just + /// cannot spend it until the block lands. + pub owner_spend_in_flight: bool, /// The `reveal_txid` stamped on the bid commitment row (if any). pub reveal_txid: Option, /// Status of the local tx_draft matching `reveal_txid` (if one exists). @@ -536,20 +562,30 @@ pub(crate) fn find_name_action_context( // lands back on the bid coin's own address, see `build_reveal_draft`), so // only the covenant type differs between the two queries below. // - // Looked up by NAME HASH across the profile, not through one commitment's - // address. Every bid lands on its own rotated address, so an address-scoped - // lookup answers for a single bid — and which one it picked was not even - // well defined: `created_at` has second resolution, so several bids placed - // in the same second order arbitrarily. let name_hash_hex = hex::encode(names::hash_name(name).unwrap_or([0u8; 32])); - let bid_coin = queries::find_unspent_covenant_utxos_by_name_hash( - conn, - profile_id, - sync::COV_BID as i64, - &name_hash_hex, - ) - .ok() - .and_then(|v| v.into_iter().next()); + // Every bid of THIS auction, not the newest one and not every bid the + // profile has ever placed on the name. Both wrong answers were live: + // picking one commitment's address was never well defined (`created_at` + // has second resolution, so bids placed in the same second order + // arbitrarily), and searching by name hash across the profile let a + // lockup stranded in a LAPSED auction answer for this one. A stranded BID + // coin can never be revealed — `start == ns.height` is consensus — so it + // kept Reveal enabled on a fully revealed name, and every press failed + // with "no unspent bid coin". This is the same set `build_reveal_draft` + // builds its transaction from, so the button and the builder cannot + // disagree. + let bid_coin = commitments.iter().find_map(|b| { + queries::find_unspent_covenant_utxo( + conn, + profile_id, + &b.address, + sync::COV_BID as i64, + name, + &b.name_hash_hex, + ) + .ok() + .flatten() + }); let reveal_coins = queries::find_unspent_covenant_utxos_by_name_hash( conn, profile_id, @@ -563,7 +599,7 @@ pub(crate) fn find_name_action_context( // A reveal coin that is NOT the name's owner is a losing bid this wallet // can still reclaim. Outbidding yourself leaves exactly this: you own the // name AND hold losing reveals on it. - let redeemable_reveal_count = reveal_coins + let (redeemable_reveal_count, redeemable_value_doos) = reveal_coins .iter() .filter(|c| { owner_coin @@ -571,7 +607,7 @@ pub(crate) fn find_name_action_context( .map(|o| !(o.txid == c.txid && o.vout == c.vout)) .unwrap_or(true) }) - .count() as i64; + .fold((0i64, 0i64), |(n, sum), c| (n + 1, sum + c.value as i64)); let reveal_coin = reveal_coins.into_iter().next(); let owner_cov_type = owner_coin.as_ref().map(|c| c.covenant_type); let nh = owner_coin.as_ref().and_then(|c| c.name_height); @@ -616,8 +652,36 @@ pub(crate) fn find_name_action_context( let has_pending_open_draft = queries::has_pending_draft_for_name(conn, profile_id, "open", name).unwrap_or(false); let has_pending_open = has_pending_open_coin || has_pending_open_draft; - let pending_broadcast_action = - queries::pending_broadcast_action_for_name(conn, profile_id, name).unwrap_or(None); + let pending_actions = + queries::pending_broadcast_actions_for_name(conn, profile_id, name).unwrap_or_default(); + let pending_broadcast_action = pending_actions.first().cloned(); + + // `get_name_coin` answers "can we spend it" and returns unspent coins + // only. Between our own broadcast and its block the answer is no while the + // name is still ours, and conflating the two declared a just-registered + // name lost. + // + // 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 — and hsd drops a coin from that set as soon as + // a MEMPOOL transaction spends it, which is exactly this window. 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 actions that + // spend the owner coin count; a bid or a redeem in flight says nothing + // about ownership. + let owner_spend_in_flight = owner_coin.is_none() + && pending_actions.iter().any(|a| { + matches!( + a.as_str(), + "register" + | "update" + | "transfer" + | "finalize" + | "cancel_transfer" + | "renew" + | "revoke" + ) + }); // Reveal-in-flight evidence: the commitment row's `reveal_txid` (stamped // either by our own broadcast in `build_reveal_draft`, or by `chain_scan` @@ -648,6 +712,8 @@ pub(crate) fn find_name_action_context( stranded_bid_count, stranded_lockup_doos, redeemable_reveal_count, + redeemable_value_doos, + owner_spend_in_flight, reveal_txid, reveal_draft_status, bid_value_doos, @@ -800,18 +866,14 @@ async fn evaluate_name_action_capabilities( }; let stats = name_info.get("info").and_then(|i| i.get("stats")); - // Symmetric ownership: even on a synced node the owner-coin scan may - // not have reached this name yet. Explorer evidence (recorded owner - // address is ours) still classifies it as owned — but WITHOUT a real - // node-synced owner coin, every spend stays locked, exactly as on the - // node-unreachable path. This keeps the invariant airtight: explorer - // evidence gives classification, never spend capability. - let explorer_owned = tracked_owner_address - .as_deref() - .map(|a| profile_addrs.iter().any(|p| p == a)) - .unwrap_or(false); - let owns_name = action_ctx.has_owner_coin || explorer_owned; - let spend_locked = !action_ctx.has_owner_coin; + let NameOwnership { + owns_name, + spend_locked, + } = derive_name_ownership( + &action_ctx, + tracked_owner_address.as_deref(), + &profile_addrs, + ); Ok(build_name_action_capabilities( name, raw_phase.clone(), @@ -860,15 +922,14 @@ async fn evaluate_name_action_capabilities( .as_deref() .map(|s| s.to_uppercase()) .unwrap_or_default(); - // Owned per explorer evidence: the recorded owner address is ours. - let explorer_owned = tracked - .owner_address - .as_deref() - .map(|a| profile_addrs.iter().any(|p| p == a)) - .unwrap_or(false); - let owns_name = action_ctx.has_owner_coin || explorer_owned; - // No node-synced owner coin → nothing may build a spend. - let spend_locked = !action_ctx.has_owner_coin; + let NameOwnership { + owns_name, + spend_locked, + } = derive_name_ownership( + &action_ctx, + tracked.owner_address.as_deref(), + &profile_addrs, + ); // Days-until-expire from tracked chain evidence, so the modal's // `expiringSoon` matches the WalletView banner / Renewals screen // instead of staying silent for lack of live node stats. @@ -1017,13 +1078,23 @@ pub(crate) fn build_name_action_capabilities( .unwrap_or(false); let can_spend_as_owner = owns_name && name_is_registered; let not_registered_reason = "the name is not registered yet"; - + let transfer_pending = action_ctx.transfer_has_items.unwrap_or(false); + + // Update is the one ownership action a pending transfer takes away. hsd + // lets a TRANSFER coin go to UPDATE, RENEW, FINALIZE or REVOKE + // (`rules.verifyCovenants`), and the UPDATE branch there *is* the cancel: + // the node accepts it and the transfer is gone. Offering it as "edit your + // DNS records" makes losing a pending transfer a side effect of a button + // that says nothing about transfers. Cancelling stays available under its + // own name. let can_update = NameActionCapability { - allowed: can_spend_as_owner, + allowed: can_spend_as_owner && !transfer_pending, reason: if !owns_name { Some("wallet does not control this name".into()) } else if !name_is_registered { Some(not_registered_reason.into()) + } else if transfer_pending { + Some("a transfer is pending — updating records would cancel it".into()) } else { None }, @@ -1051,12 +1122,17 @@ pub(crate) fn build_name_action_capabilities( }, }; + // Cancelling needs a transfer to cancel — the same condition `can_finalize` + // has always carried. Without it the button was live on every registered + // name and built an UPDATE that changes nothing and costs a fee. let can_cancel_transfer = NameActionCapability { - allowed: can_spend_as_owner, + allowed: can_spend_as_owner && transfer_pending, reason: if !owns_name { Some("wallet does not control this name".into()) } else if !name_is_registered { Some(not_registered_reason.into()) + } else if !transfer_pending { + Some("name is not in TRANSFER state".into()) } else { None }, @@ -1177,6 +1253,18 @@ pub(crate) fn build_name_action_capabilities( next_action_reason = Some("Your bid is placed. Wait for the reveal window to open.".into()); } + // `LostNeedsRedeem` is reached two ways, and only one of them is a loss. + // Bidding against yourself and winning leaves you owning the name AND + // holding your own losing reveals — the ordinary outcome of placing + // several bids, and the whole reason multi-bid exists. Telling that user + // "Your bid lost" on a name they just registered is simply false. + if matches!(task_state, AuctionTaskState::LostNeedsRedeem) && owns_name { + next_action_reason = Some( + "You own this name. These are your own losing bids on it — redeem them to reclaim the lockup." + .into(), + ); + } + // 7. Extract countdown from stats. let (countdown_label, countdown_blocks, countdown_hours) = names_pure::extract_countdown(raw_phase, stats); @@ -1187,6 +1275,8 @@ pub(crate) fn build_name_action_capabilities( phase, task_state, owns_name, + name_is_registered, + transfer_pending, has_bid_commitment: action_ctx.has_bid_commitment, has_bid_coin: action_ctx.has_bid_coin, has_reveal_coin: action_ctx.has_reveal_coin, @@ -1217,6 +1307,8 @@ pub(crate) fn build_name_action_capabilities( pending_broadcast_action: action_ctx.pending_broadcast_action.clone(), stranded_bid_count: action_ctx.stranded_bid_count, stranded_lockup_doos: action_ctx.stranded_lockup_doos, + redeemable_reveal_count: action_ctx.redeemable_reveal_count, + redeemable_value_doos: action_ctx.redeemable_value_doos, } } @@ -1231,6 +1323,11 @@ pub(crate) fn conservative_capabilities(name: &str, reason: &str) -> NameActionC phase: "UNKNOWN".into(), task_state: AuctionTaskState::UnavailableOther, owns_name: false, + // No node-synced owner coin reached us, so nothing proves the name is + // registered. Claiming it would unlock the ownership sections on a + // name we cannot even read. + name_is_registered: false, + transfer_pending: false, has_bid_commitment: false, has_bid_coin: false, has_reveal_coin: false, @@ -1263,6 +1360,41 @@ pub(crate) fn conservative_capabilities(name: &str, reason: &str) -> NameActionC pending_broadcast_action: None, stranded_bid_count: 0, stranded_lockup_doos: 0, + redeemable_reveal_count: 0, + redeemable_value_doos: 0, + } +} + +/// What the wallet may claim about a name, and what it may do with it. +/// +/// Two questions that look like one and are not. Both call sites used to spell +/// out the same three lines, and the gap between the questions is where a bug +/// lived: `get_name_coin` returns unspent coins only, so our own unconfirmed +/// spend of the owner coin made the wallet conclude it did not own the name — +/// on a CLOSED name still holding reveals, that is the shape of a lost auction. +pub(crate) struct NameOwnership { + /// May we say this name is ours? Survives an unconfirmed spend of the + /// owner coin, and explorer evidence alone is enough. + pub owns_name: bool, + /// May we build a transaction for it? Only a node-synced, unspent owner + /// coin unlocks this — explorer evidence classifies, it never unlocks, and + /// a spend already in flight must not be raced. + pub spend_locked: bool, +} + +/// `owner_address` is the owner recorded for the name (from the node payload +/// or the tracked row); it counts as ours when it is one of `profile_addrs`. +pub(crate) fn derive_name_ownership( + ctx: &NameActionContext, + owner_address: Option<&str>, + profile_addrs: &[String], +) -> NameOwnership { + let explorer_owned = owner_address + .map(|a| profile_addrs.iter().any(|p| p == a)) + .unwrap_or(false); + NameOwnership { + owns_name: ctx.has_owner_coin || ctx.owner_spend_in_flight || explorer_owned, + spend_locked: !ctx.has_owner_coin, } } @@ -3348,6 +3480,9 @@ pub(crate) fn build_finalize_with_payment_draft_inner( #[cfg(test)] mod tests { use super::*; + // Only the tests need it: the capability code compares against + // COV_REGISTER, not against any particular later covenant. + use crate::noncustodial::sync::COV_TRANSFER; use serde_json::json; // ------------------------------------------------------------------ @@ -3371,6 +3506,8 @@ mod tests { stranded_bid_count: 0, stranded_lockup_doos: 0, redeemable_reveal_count: 0, + redeemable_value_doos: 0, + owner_spend_in_flight: false, reveal_txid: None, reveal_draft_status: None, bid_value_doos: None, @@ -4388,6 +4525,125 @@ mod tests { } } + // ================================================================== + // derive_name_ownership + // ================================================================== + + fn addrs() -> Vec { + vec!["ours1".to_string(), "ours2".to_string()] + } + + /// The plain case: we hold the coin, so we own it and may spend it. + #[test] + fn ownership_owner_coin_owns_and_unlocks() { + let ctx = NameActionContext { + has_owner_coin: true, + ..ctx_default() + }; + let o = derive_name_ownership(&ctx, Some("ours1"), &addrs()); + assert!(o.owns_name); + assert!(!o.spend_locked); + } + + /// The bug this function was extracted for. Our own unconfirmed spend of + /// the owner coin removes it from the node's unspent set, so the coin + /// lookup finds nothing — but the name is still ours, and will be whether + /// or not the transaction confirms. Spending stays locked until it does. + #[test] + fn ownership_survives_our_own_unconfirmed_owner_spend() { + let ctx = NameActionContext { + has_owner_coin: false, + owner_spend_in_flight: true, + ..ctx_default() + }; + let o = derive_name_ownership(&ctx, None, &[]); + assert!(o.owns_name, "a register in flight is not a lost name"); + assert!(o.spend_locked, "and we still cannot act until it lands"); + } + + /// Explorer evidence classifies, it never unlocks: the recorded owner + /// address is ours but no node-synced coin backs it. + #[test] + fn ownership_from_explorer_address_owns_but_stays_locked() { + let o = derive_name_ownership(&ctx_default(), Some("ours2"), &addrs()); + assert!(o.owns_name); + assert!(o.spend_locked); + } + + /// Someone else's address is not evidence of anything. + #[test] + fn ownership_of_a_stranger_address_is_not_ours() { + let o = derive_name_ownership(&ctx_default(), Some("theirs"), &addrs()); + assert!(!o.owns_name); + assert!(o.spend_locked); + } + + /// No coin, nothing in flight, no recorded owner: not ours. + #[test] + fn ownership_with_no_evidence_at_all() { + let o = derive_name_ownership(&ctx_default(), None, &addrs()); + assert!(!o.owns_name); + assert!(o.spend_locked); + } + + /// `LostNeedsRedeem` is reached two ways and only one is a loss. A wallet + /// that outbid itself owns the name and holds its own losing reveals, and + /// "Your bid lost" is false for it — on a name it just registered. + #[test] + fn redeem_on_a_name_you_own_is_not_described_as_losing() { + let ctx = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_REGISTER as i64), + has_reveal_coin: true, + redeemable_reveal_count: 3, + redeemable_value_doos: 28_000_000, + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "CLOSED".into(), + "CLOSED", + None, + &ctx, + /* owns_name */ true, + false, + None, + Network::Main, + ); + assert!(matches!(caps.task_state, AuctionTaskState::LostNeedsRedeem)); + let reason = caps.next_action_reason.unwrap_or_default(); + assert!( + !reason.contains("lost"), + "a name you own and registered did not lose: {reason:?}" + ); + assert!(reason.contains("own this name"), "got {reason:?}"); + } + + /// And the genuine loss keeps saying so — the branch above must not + /// swallow the case it was carved out of. + #[test] + fn redeem_on_a_name_you_lost_still_says_the_bid_lost() { + let ctx = NameActionContext { + has_reveal_coin: true, + redeemable_reveal_count: 1, + redeemable_value_doos: 5_000_000, + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "CLOSED".into(), + "CLOSED", + None, + &ctx, + /* owns_name */ false, + false, + None, + Network::Main, + ); + assert!(matches!(caps.task_state, AuctionTaskState::LostNeedsRedeem)); + assert!(caps.next_action_reason.unwrap_or_default().contains("lost")); + } + /// The same actions on a genuinely registered name stay available. #[test] fn ownership_actions_stay_available_once_registered() { @@ -4413,11 +4669,79 @@ mod tests { assert!(caps.can_revoke.allowed); } + /// The UI needs the same "is this name actually registered?" answer the + /// capability gates are computed from. Re-deriving it in TypeScript from + /// `ownsName` is what put DNS, Ownership and Sign message on screen for a + /// name the wallet was merely leading the auction on, so the backend + /// states it once and the frontend reads it. + #[test] + fn name_is_registered_is_reported_alongside_the_gates_it_drives() { + let reveal_owner = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_REVEAL as i64), + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "REVEAL".into(), + "REVEAL", + None, + &reveal_owner, + true, + false, + None, + Network::Main, + ); + assert!( + !caps.name_is_registered, + "a REVEAL owner coin means the name is not registered yet" + ); + + let registered_owner = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_REGISTER as i64), + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "CLOSED".into(), + "CLOSED", + None, + ®istered_owner, + true, + false, + None, + Network::Main, + ); + assert!(caps.name_is_registered); + } + + /// With no node-synced owner coin at all there is nothing to prove the name + /// is registered, and the conservative fallback must not claim it is. + #[test] + fn name_is_registered_is_false_without_an_owner_coin() { + let caps = build_name_action_capabilities( + "n".into(), + "CLOSED".into(), + "CLOSED", + None, + &ctx_default(), + false, + false, + None, + Network::Main, + ); + assert!(!caps.name_is_registered); + assert!(!conservative_capabilities("n", "node unreachable").name_is_registered); + } + #[test] fn build_can_redeem_allowed() { let ctx = NameActionContext { has_reveal_coin: true, redeemable_reveal_count: 1, + redeemable_value_doos: 0, + owner_spend_in_flight: false, ..ctx_default() }; let caps = build_name_action_capabilities( @@ -4492,6 +4816,8 @@ mod tests { let ctx = NameActionContext { has_reveal_coin: true, redeemable_reveal_count: 2, + redeemable_value_doos: 0, + owner_spend_in_flight: false, ..ctx_default() }; let caps = build_name_action_capabilities( @@ -4519,6 +4845,8 @@ mod tests { let ctx = NameActionContext { has_reveal_coin: true, redeemable_reveal_count: 0, + redeemable_value_doos: 0, + owner_spend_in_flight: false, ..ctx_default() }; let caps = build_name_action_capabilities( @@ -4665,8 +4993,13 @@ mod tests { #[test] fn build_owner_actions_allowed_when_owned() { + // An ordinary owned name: registered, with no transfer in flight. + // This fixture used to also set `transfer_has_items: Some(true)` and + // assert Finalize — two different states in one case, which is what + // let "Update is fine" and "a transfer is pending" both look true. + // The pending-transfer state has its own test below. let ctx = NameActionContext { - transfer_has_items: Some(true), + transfer_has_items: Some(false), // A wallet that owns a name it can spend holds a REGISTER-or-later // coin; leaving this unset described a state production never has. has_owner_coin: true, @@ -4688,15 +5021,140 @@ mod tests { assert_eq!(caps.can_update.reason, None); assert!(caps.can_transfer.allowed); assert_eq!(caps.can_transfer.reason, None); - assert!(caps.can_cancel_transfer.allowed); - assert_eq!(caps.can_cancel_transfer.reason, None); + // Nothing to cancel either — same reason as Finalize below. + assert!(!caps.can_cancel_transfer.allowed); + assert_eq!( + caps.can_cancel_transfer.reason.as_deref(), + Some("name is not in TRANSFER state") + ); assert!(caps.can_renew.allowed); assert_eq!(caps.can_renew.reason, None); assert!(caps.can_revoke.allowed); assert_eq!(caps.can_revoke.reason, None); - // finalize allowed since transfer_has_items = Some(true). + // Nothing to finalize: no transfer is in flight. + assert!(!caps.can_finalize.allowed); + assert_eq!( + caps.can_finalize.reason.as_deref(), + Some("name is not in TRANSFER state") + ); + } + + /// A pending transfer is not a state Update belongs in. hsd lets a + /// TRANSFER coin go to UPDATE, RENEW, FINALIZE or REVOKE + /// (`rules.verifyCovenants`) — and that UPDATE *is* how a transfer is + /// cancelled. So the node accepts the transaction and the user's pending + /// transfer quietly disappears, with nothing on screen having said so. + /// Cancelling stays available, as the button that says what it does. + #[test] + fn update_is_refused_while_a_transfer_is_pending() { + let ctx = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_TRANSFER as i64), + transfer_has_items: Some(true), + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "TRANSFER".into(), + "TRANSFER", + None, + &ctx, + true, + false, + None, + Network::Main, + ); + assert!( + !caps.can_update.allowed, + "updating records mid-transfer silently cancels the transfer" + ); + assert_eq!( + caps.can_update.reason.as_deref(), + Some("a transfer is pending — updating records would cancel it") + ); + // The actions that genuinely belong to a pending transfer stay live. assert!(caps.can_finalize.allowed); - assert_eq!(caps.can_finalize.reason, None); + assert!(caps.can_cancel_transfer.allowed); + } + + /// Cancelling needs something to cancel. `can_finalize` has always + /// required `transfer_has_items`; its sibling did not, so on an ordinary + /// registered name the button was live and built an UPDATE that changes + /// nothing and costs a fee. + #[test] + fn cancel_transfer_is_refused_when_no_transfer_is_pending() { + let ctx = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_REGISTER as i64), + transfer_has_items: Some(false), + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "CLOSED".into(), + "CLOSED", + None, + &ctx, + true, + false, + None, + Network::Main, + ); + assert!(!caps.can_cancel_transfer.allowed); + assert_eq!( + caps.can_cancel_transfer.reason.as_deref(), + Some("name is not in TRANSFER state") + ); + } + + /// The UI has to close the records section for exactly the names + /// `can_update` refuses, and the only honest way to know is to be told. + /// Deriving it from the phase string instead is a second source of truth: + /// `transfer_has_items` and `phase == "TRANSFER"` can disagree, and then + /// the section and the button it contains disagree too. + #[test] + fn transfer_pending_is_reported_and_tracks_the_gate_not_the_phase() { + let mid_transfer = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_TRANSFER as i64), + transfer_has_items: Some(true), + ..ctx_default() + }; + // Deliberately NOT the TRANSFER phase: the items are what decide. + let caps = build_name_action_capabilities( + "n".into(), + "CLOSED".into(), + "CLOSED", + None, + &mid_transfer, + true, + false, + None, + Network::Main, + ); + assert!(caps.transfer_pending); + assert!(!caps.can_update.allowed, "the gate agrees with the flag"); + + let settled = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_REGISTER as i64), + transfer_has_items: Some(false), + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "CLOSED".into(), + "CLOSED", + None, + &settled, + true, + false, + None, + Network::Main, + ); + assert!(!caps.transfer_pending); + assert!(caps.can_update.allowed); + assert!(!conservative_capabilities("n", "unreachable").transfer_pending); } #[test] diff --git a/src-tauri/src/commands/tx.rs b/src-tauri/src/commands/tx.rs index 67b4f447..31a89634 100644 --- a/src-tauri/src/commands/tx.rs +++ b/src-tauri/src/commands/tx.rs @@ -1324,6 +1324,17 @@ pub async fn sign_name_message( let coin = db::queries::get_name_coin(&conn, &id, &name)?.ok_or_else(|| { AppError::InvalidInput(format!("wallet does not own '{name}' (sync/own it first)")) })?; + // Holding the owner coin is not owning the name. `get_name_coin` + // resolves whatever `tracked_name_states.owner_txid` points at, and + // during REVEAL that is our own REVEAL coin — hsd reports the highest + // revealer as the owner long before anyone has won. Signing it would + // produce a well-formed claim of ownership that every verifier + // resolves as false. Same rule as the ownership capabilities. + if coin.covenant_type < crate::noncustodial::sync::COV_REGISTER as i64 { + return Err(AppError::InvalidInput(format!( + "the name '{name}' is not registered yet — there is no ownership to prove" + ))); + } let settings = db::queries::get_settings(&conn)?; ( profile.account_index as u32, diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index 9f9246aa..eee3c97c 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1572,6 +1572,35 @@ pub fn has_pending_draft_for_name( /// not left the device, and `confirmed`/`dropped`/`failed` are settled. When /// several qualify — a name can legitimately have more than one in flight — the /// most recent wins, which is the one the user just sent. +/// Every action this wallet has broadcast for `name` and the chain has not +/// mined, newest first. More than one can be in flight at once — a register +/// and a redeem on the same name spend different coins and are independent — +/// so a single answer has to pick, and `created_at` has second resolution: +/// two drafts made in the same second order arbitrarily. Callers that ask +/// "is a transaction of this kind in flight?" must look at all of them. +pub fn pending_broadcast_actions_for_name( + conn: &rusqlite::Connection, + profile_id: &str, + name: &str, +) -> Result, AppError> { + let sql = format!( + "SELECT {DRAFT_COLS} FROM wallet_tx_drafts + WHERE wallet_profile_id = ?1 + AND status IN ('broadcast_pending','broadcasted') + ORDER BY created_at DESC" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params![profile_id], row_to_draft)?; + let mut out = Vec::new(); + for r in rows { + let row = r?; + if draft_summary_covers_name(&row.summary_json, name) { + out.push(row.action); + } + } + Ok(out) +} + pub fn pending_broadcast_action_for_name( conn: &rusqlite::Connection, profile_id: &str, diff --git a/src-tauri/src/tests/name_capabilities_tests.rs b/src-tauri/src/tests/name_capabilities_tests.rs index 43b1d2dd..6b27e19a 100644 --- a/src-tauri/src/tests/name_capabilities_tests.rs +++ b/src-tauri/src/tests/name_capabilities_tests.rs @@ -46,6 +46,8 @@ fn ctx( stranded_bid_count: 0, stranded_lockup_doos: 0, redeemable_reveal_count: 0, + redeemable_value_doos: 0, + owner_spend_in_flight: false, reveal_txid, reveal_draft_status, bid_value_doos, @@ -752,6 +754,8 @@ fn cap_closed_phase_can_redeem_lost_bid() { let action_ctx = NameActionContext { // A reveal coin that is not the name's owner — a bid that lost. redeemable_reveal_count: 1, + redeemable_value_doos: 0, + owner_spend_in_flight: false, ..ctx( false, false, true, false, None, None, None, 0, false, None, None, None, ) @@ -777,6 +781,8 @@ fn cap_closed_phase_can_redeem_lost_bid() { fn cap_closed_phase_cannot_redeem_when_the_only_reveal_won() { let action_ctx = NameActionContext { redeemable_reveal_count: 0, + redeemable_value_doos: 0, + owner_spend_in_flight: false, ..ctx( false, false, true, false, None, None, None, 0, false, None, None, None, ) @@ -807,6 +813,8 @@ fn cap_closed_phase_cannot_redeem_when_the_only_reveal_won() { fn cap_closed_phase_can_redeem_own_losing_bids_while_owning_the_name() { let action_ctx = NameActionContext { redeemable_reveal_count: 2, + redeemable_value_doos: 0, + owner_spend_in_flight: false, ..ctx( false, false, true, false, None, None, None, 0, false, None, None, None, ) diff --git a/src-tauri/src/tests/names_action_context_tests.rs b/src-tauri/src/tests/names_action_context_tests.rs index 92ceab87..ddf95834 100644 --- a/src-tauri/src/tests/names_action_context_tests.rs +++ b/src-tauri/src/tests/names_action_context_tests.rs @@ -465,6 +465,165 @@ fn find_name_action_context_reports_a_stranded_bid_from_a_lapsed_auction() { assert_eq!(own.stranded_bid_count, 0); } +/// Reported from a live regtest wallet: Reveal stayed enabled on a name whose +/// bids had all been revealed, and failed every time with "no unspent bid +/// coin". `has_bid_coin` was resolved by name hash across the whole profile +/// while the reveal draft resolves it from the commitments of THIS auction, so +/// a lockup stranded in a lapsed auction — unspendable by definition — kept +/// answering "yes, there is a bid coin" forever. +#[test] +fn find_name_action_context_does_not_count_a_stranded_coin_as_revealable() { + let conn = test_db(); + seed_profile(&conn); + seed_derived_address(&conn, ADDRESS, 0, 0); + let other = "rs1qotheraddressfortheliveauctionbid00000000"; + seed_derived_address(&conn, other, 0, 1); + let nh_hex = hex::encode(crate::noncustodial::names::hash_name(NAME).unwrap()); + let cov = format!(r#"{{"type":{},"items":["{nh_hex}"]}}"#, sync::COV_BID); + + // A lapsed auction's bid whose BID coin is still sitting there. + seed_bid_commitment(&conn, NAME, &nh_hex, ADDRESS); + db::queries::set_auction_heights(&conn, PROFILE, "blind", 111, 132).unwrap(); + seed_tracked_utxo( + &conn, + "oldbid", + 0, + ADDRESS, + sync::COV_BID as i64, + Some(&cov), + ); + + // This auction's bid, already revealed — its BID coin is spent, so none is + // seeded for it. + db::queries::insert_bid_commitment( + &conn, PROFILE, NAME, &nh_hex, other, 0, 0, 1_000_000, 3_000_000, "nonce2", "blind2", + ) + .unwrap(); + db::queries::set_auction_heights(&conn, PROFILE, "blind2", 779, 800).unwrap(); + + let ctx = find_name_action_context(&conn, PROFILE, NAME, Some(779)).unwrap(); + assert_eq!(ctx.existing_bid_count, 1, "one bid belongs to this auction"); + assert_eq!( + ctx.stranded_bid_count, 1, + "and one is stranded in the old one" + ); + assert!( + !ctx.has_bid_coin, + "nothing left to reveal in this auction — the stranded coin is not revealable" + ); +} + +/// Outbidding yourself leaves you owning the name AND holding losing reveals +/// on it. "Redeem" names a covenant, not what pressing it does, so the amount +/// it reclaims travels with the capability. +#[test] +fn find_name_action_context_totals_what_a_redeem_would_reclaim() { + let conn = test_db(); + seed_profile(&conn); + seed_derived_address(&conn, ADDRESS, 0, 0); + let nh_hex = hex::encode(crate::noncustodial::names::hash_name(NAME).unwrap()); + let cov = format!(r#"{{"type":{},"items":["{nh_hex}"]}}"#, sync::COV_REVEAL); + // Two losing reveals. No owner coin is recorded, so neither is the winner. + seed_tracked_utxo( + &conn, + "loser1", + 0, + ADDRESS, + sync::COV_REVEAL as i64, + Some(&cov), + ); + seed_tracked_utxo( + &conn, + "loser2", + 0, + ADDRESS, + sync::COV_REVEAL as i64, + Some(&cov), + ); + + let ctx = find_name_action_context(&conn, PROFILE, NAME, Some(779)).unwrap(); + assert_eq!(ctx.redeemable_reveal_count, 2); + assert_eq!( + ctx.redeemable_value_doos, 200_000, + "the sum of the coins a REDEEM would spend" + ); +} + +/// Reported live. Between broadcasting REGISTER and its block, the node stops +/// reporting the owner coin as unspent — a mempool spend is enough — so `sync` +/// marks it spent and `get_name_coin` finds nothing. `owns_name` collapsed to +/// false, and CLOSED + not owned + holding reveals is the shape of a LOST +/// auction: the modal announced "Lost — Redeem Now" in red over a name the +/// user had just paid to register. +/// +/// Note what the wallet cannot know here: `sync` marks a coin spent by +/// diffing the node's live coin set and writes the sentinel `'spent'`, never +/// the spending txid. The evidence that ownership survives is our own +/// unconfirmed draft for this name, and only for the actions that spend the +/// owner coin. +#[test] +fn find_name_action_context_knows_the_owner_coin_is_only_spent_in_flight() { + let conn = test_db(); + seed_profile(&conn); + seed_derived_address(&conn, ADDRESS, 0, 0); + let nh_hex = hex::encode(crate::noncustodial::names::hash_name(NAME).unwrap()); + let cov = format!(r#"{{"type":{},"items":["{nh_hex}"]}}"#, sync::COV_REVEAL); + + // The winning reveal, marked spent exactly the way `sync` marks one. + seed_tracked_utxo( + &conn, + "winner", + 0, + ADDRESS, + sync::COV_REVEAL as i64, + Some(&cov), + ); + conn.execute( + "UPDATE tracked_utxos SET spent_by_txid = 'spent' WHERE txid = 'winner'", + [], + ) + .unwrap(); + seed_tracked_name_state( + &conn, + NAME, + &nh_hex, + "CLOSED", + Some("winner"), + Some(0), + Some(779), + ); + + // Nothing of ours in flight: the coin really is gone. + let ctx = find_name_action_context(&conn, PROFILE, NAME, Some(779)).unwrap(); + assert!(!ctx.has_owner_coin); + assert!(!ctx.owner_spend_in_flight); + + // A redeem in flight spends reveals, not the owner coin — it says nothing + // about whether we still hold the name. + seed_draft(&conn, "d-red", "redeem", NAME); + db::queries::update_tx_draft_status(&conn, "d-red", "broadcasted", None, Some("redtx")) + .unwrap(); + let ctx = find_name_action_context(&conn, PROFILE, NAME, Some(779)).unwrap(); + assert!( + !ctx.owner_spend_in_flight, + "a redeem does not spend the owner coin" + ); + + // Our own register, broadcast and unmined, does. + seed_draft(&conn, "d-reg", "register", NAME); + db::queries::update_tx_draft_status(&conn, "d-reg", "broadcasted", None, Some("regtx")) + .unwrap(); + let ctx = find_name_action_context(&conn, PROFILE, NAME, Some(779)).unwrap(); + assert!( + !ctx.has_owner_coin, + "still unspendable — that part was right" + ); + assert!( + ctx.owner_spend_in_flight, + "our own unconfirmed register spent it, so the name is still ours" + ); +} + /// A spent BID coin is a bid that was revealed and settled — nothing stranded. #[test] fn find_name_action_context_does_not_strand_a_spent_bid() { diff --git a/src-tauri/src/tests/node_lifecycle_tests.rs b/src-tauri/src/tests/node_lifecycle_tests.rs index f53489cd..babb9294 100644 --- a/src-tauri/src/tests/node_lifecycle_tests.rs +++ b/src-tauri/src/tests/node_lifecycle_tests.rs @@ -115,7 +115,18 @@ impl Harness { /// Returns `None` if the fake was only asked for `--version` (no spawn), /// or if the script hasn't been invoked as a node yet. fn recorded_argv(&self) -> Option { - std::fs::read_to_string(self.dir.join("fake-hsd-argv.log")).ok() + // An EMPTY read is not a recording. The fake hsd records with + // `echo "$@" > log`, and the shell creates and truncates that file + // before `echo` writes a byte into it — so between the two syscalls + // the file exists and is empty. Returning `Some("")` there ended the + // caller's poll loop on the first tick and left it asserting against + // an empty argv, which is the flake this helper caused: about one run + // in twenty, with a message claiming hsd was never told its network. + let s = std::fs::read_to_string(self.dir.join("fake-hsd-argv.log")).ok()?; + if s.trim().is_empty() { + return None; + } + Some(s) } } diff --git a/src-tauri/src/tests/node_tests.rs b/src-tauri/src/tests/node_tests.rs index 778c3630..d90b24c5 100644 --- a/src-tauri/src/tests/node_tests.rs +++ b/src-tauri/src/tests/node_tests.rs @@ -1,5 +1,6 @@ use crate::commands::node; use crate::noncustodial::network::Network; +use serial_test::serial; use std::path::Path; #[test] @@ -144,6 +145,7 @@ fn test_node_start_error_log_with_index_mismatch() { // --- hsd_candidates tests --- #[test] +#[serial(hsd_home)] fn test_hsd_candidates_returns_default_paths() { let candidates = node::hsd_candidates(); // Should always include the two default paths @@ -154,6 +156,7 @@ fn test_hsd_candidates_returns_default_paths() { } #[test] +#[serial(hsd_home)] fn test_hsd_candidates_includes_home_paths() { // If HOME is set, should include home-based paths if std::env::var("HOME").is_ok() { @@ -174,12 +177,14 @@ fn test_hsd_candidates_includes_home_paths() { // --- find_hsd_binary tests --- #[test] +#[serial(hsd_home)] fn test_find_hsd_binary_with_override() { let result = node::find_hsd_binary(Some("/custom/hsd")); assert_eq!(result, "/custom/hsd"); } #[test] +#[serial(hsd_home)] fn test_find_hsd_binary_with_empty_override() { // Empty override should fall through to candidates/which let result = node::find_hsd_binary(Some("")); @@ -188,6 +193,7 @@ fn test_find_hsd_binary_with_empty_override() { } #[test] +#[serial(hsd_home)] fn test_find_hsd_binary_with_none() { // None override should use candidates/which let result = node::find_hsd_binary(None); @@ -198,12 +204,14 @@ fn test_find_hsd_binary_with_none() { // --- find_hsd_binary additional tests --- #[test] +#[serial(hsd_home)] fn test_find_hsd_binary_override_whitespace_falls_through() { let result = node::find_hsd_binary(Some(" ")); assert!(!result.is_empty()); } #[test] +#[serial(hsd_home)] fn test_find_hsd_binary_override_with_path() { let result = node::find_hsd_binary(Some("/usr/local/bin/hsd")); assert_eq!(result, "/usr/local/bin/hsd"); @@ -212,6 +220,7 @@ fn test_find_hsd_binary_override_with_path() { // --- hsd_candidates edge cases --- #[test] +#[serial(hsd_home)] fn test_hsd_candidates_does_not_contain_duplicates() { let candidates = node::hsd_candidates(); let mut sorted = candidates.clone(); @@ -344,6 +353,7 @@ fn test_version_prerelease_of_min_is_accepted() { // --- hsd_candidates: nvm-managed node discovery loop ------------------------- #[test] +#[serial(hsd_home)] fn test_hsd_candidates_discovers_nvm_managed_hsd() { // The nvm branch (`~/.nvm/versions/node//bin/hsd`) only runs when that // directory tree exists AND contains an hsd binary — otherwise the loop is @@ -375,6 +385,7 @@ fn test_hsd_candidates_discovers_nvm_managed_hsd() { // --- find_hsd_binary: which/PATH fallback (no matching candidate) ------------ #[test] +#[serial(hsd_home)] fn test_find_hsd_binary_none_override_returns_nonempty() { // With no override and (typically) no installed hsd on a CI box, this walks // candidates → `which hsd` → the bare "hsd" fallback. Whichever branch wins, diff --git a/src-tauri/src/tests/sign_name_message_tests.rs b/src-tauri/src/tests/sign_name_message_tests.rs index 3a58f91c..ce706416 100644 --- a/src-tauri/src/tests/sign_name_message_tests.rs +++ b/src-tauri/src/tests/sign_name_message_tests.rs @@ -21,6 +21,7 @@ use crate::noncustodial::address; use crate::noncustodial::hd::{self, ExtendedPrivKey, ExtendedPubKey}; use crate::noncustodial::network::Network; use crate::noncustodial::session::SignerSession; +use crate::noncustodial::sync::{COV_REGISTER, COV_REVEAL}; use crate::AppState; const MNEMONIC_A: &str = "april coyote civil finger crane uncle situate moon choice wrong \ @@ -63,6 +64,14 @@ fn leaf00(mnemonic: &str) -> (String, String, String) { /// `get_name_coin` requires. Also seeds a second, unrelated profile `PROFILE_B` /// (from `MNEMONIC_B`) with no owned names, for the isolation test. fn seeded_conn() -> rusqlite::Connection { + seeded_conn_with_owner_covenant(COV_REGISTER as i64) +} + +/// As `seeded_conn`, with the owner coin's covenant spelled out. A name is +/// only genuinely owned once its owner coin is a REGISTER or later; during +/// REVEAL the highest revealer is reported as the owner while holding nothing +/// but a REVEAL coin, and that is the case worth seeding on purpose. +fn seeded_conn_with_owner_covenant(covenant_type: i64) -> rusqlite::Connection { let conn = rusqlite::Connection::open_in_memory().unwrap(); conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); db::migrations::run(&conn).unwrap(); @@ -104,8 +113,8 @@ fn seeded_conn() -> rusqlite::Connection { "INSERT INTO tracked_utxos (txid, vout, wallet_profile_id, address, script_pubkey_hex, value_doos, covenant_type, spend_class, spent_by_txid) - VALUES (?1, 0, ?2, ?3, ?4, 1000000, 4, 'name_control', NULL)", - params![COIN_TXID, PROFILE_A, addr, spk], + VALUES (?1, 0, ?2, ?3, ?4, 1000000, ?5, 'name_control', NULL)", + params![COIN_TXID, PROFILE_A, addr, spk, covenant_type], ) .unwrap(); conn.execute( @@ -201,6 +210,35 @@ async fn rejects_a_name_the_wallet_does_not_own() { assert!(matches!(err, AppError::InvalidInput(_)), "got {err:?}"); } +/// A signature over a name is a claim to own it, and until REGISTER there is +/// nothing to claim: during REVEAL `getnameinfo` reports the highest revealer +/// as the owner, so `tracked_name_states.owner_txid` points at our own REVEAL +/// coin and `get_name_coin` — which filters on no covenant at all — hands it +/// over happily. The wallet would emit a well-formed proof of ownership for a +/// name it has not won, which any verifier resolves as false. +#[tokio::test] +async fn rejects_a_name_whose_owner_coin_is_still_a_reveal() { + let conn = seeded_conn_with_owner_covenant(COV_REVEAL as i64); + let app = app_with(conn); + unlock(&app, PROFILE_A, MNEMONIC_A); + + let err = sign_name_message( + app.state(), + NAME.to_string(), + MSG.to_string(), + Some(PROFILE_A.to_string()), + ) + .await + .expect_err("leading an auction is not owning the name"); + match err { + AppError::InvalidInput(m) => assert!( + m.contains("not registered yet"), + "the reason must name the missing half, got {m:?}" + ), + other => panic!("got {other:?}"), + } +} + #[tokio::test] async fn rejects_when_the_signer_is_locked() { let conn = seeded_conn(); diff --git a/src/components/NameActionsModal.tsx b/src/components/NameActionsModal.tsx index 1752cb85..b6fb5bad 100644 --- a/src/components/NameActionsModal.tsx +++ b/src/components/NameActionsModal.tsx @@ -25,6 +25,8 @@ import { NameBidsPanel } from "./name-actions/NameBidsPanel"; import { NameSignMessage } from "./name-actions/NameSignMessage"; import { NameDetails } from "./name-actions/NameDetails"; import { OwnershipActions } from "./name-actions/OwnershipActions"; +import { UpcomingSection } from "./name-actions/UpcomingSection"; +import { resolveSections } from "../lib/nameSections"; import { PaidSwapClaim } from "./name-actions/PaidSwapClaim"; import { useUiStore } from "../stores/ui"; import { FeeRateOverride } from "./ui/FeeRateOverride"; @@ -41,6 +43,8 @@ import { formatCountdown, AUCTION_PHASE_GUIDE, taskSummaryFromCapabilities, + pendingBroadcastBadge, + redeemExplainer, validateBidInputs, } from "../lib/auction"; import { hnsToDollarydoos } from "../lib/utils"; @@ -175,7 +179,16 @@ export function NameActionsModal({ const capsPending = capsLoading || !capsFetched; // Whether the name is owned by the current wallet. - const isOwned = caps?.ownsName ?? (!!info?.owner && info?.registered === true); + // Which of the advanced sections exist at this stage, and which are still + // ahead. `ownsName` cannot answer that: during REVEAL hsd reports the + // highest revealer as the owner, so it is true for a name the wallet has + // only bid on. See `resolveSections` for the rules. + const sections = resolveSections(caps); + + // The editable records section owns the DNS read, the freshness gate and the + // one-shot seeding. All three follow the section, so they cannot drift apart + // from what is on screen. + const recordsLive = sections.records.kind === "live"; // Before REVEAL, hsd reports the on-chain `value`/`highest` as 0 — every bid // is blinded, so the network cannot know the amounts yet. But OUR own bid is @@ -201,7 +214,7 @@ export function NameActionsModal({ isError: recordsError, dataUpdatedAt: recordsUpdatedAt, refetch: refetchRecords, - } = useNameRecords(open && isOwned ? name : null, profile?.id ?? null, { + } = useNameRecords(open && recordsLive ? name : null, profile?.id ?? null, { forceFresh: true, }); @@ -219,7 +232,7 @@ export function NameActionsModal({ // the editor must not seed and UPDATE must stay disabled. const recordsFresh = open && - isOwned && + recordsLive && !recordsFetching && !recordsError && currentRecords !== undefined && @@ -253,7 +266,7 @@ export function NameActionsModal({ } }; useEffect(() => { - if (!open || !isOwned) return; + if (!open || !recordsLive) return; if (seededForName.current === name) return; // Seed ONLY from a guaranteed-fresh read. Never seed from a stale cache // or an in-flight/undefined value — that's the stale-editor bug. @@ -271,7 +284,7 @@ export function NameActionsModal({ } setRecordsJson(JSON.stringify(currentRecords?.records ?? [], null, 2)); seededForName.current = name; - }, [open, isOwned, name, recordsFresh, currentRecords]); + }, [open, recordsLive, name, recordsFresh, currentRecords]); useEffect(() => { if (!open) seededForName.current = null; }, [open]); @@ -307,21 +320,23 @@ export function NameActionsModal({ // click "Manage actions" to see their Transfer/Renew/Finalize/Revoke // controls. Names still mid-flow (just-won/needs-register, lost/needs-redeem) // keep their dedicated guided action up front instead, to avoid duplicating - // it inside the advanced section. Gated on `caps` (not the pre-caps `isOwned` + // it inside the advanced section. Gated on `caps` (not a pre-caps guess // fallback) so a still-loading response can't transiently look like // "owned, no task" and expand a section that collapses back once the real // taskState arrives. Fires once when this becomes true; the user can still // collapse it afterward via the toggle. const shouldAutoExpandManagement = - caps?.ownsName === true && + caps?.nameIsRegistered === true && caps.taskState !== "wonNeedsRegister" && caps.taskState !== "lostNeedsRedeem"; useEffect(() => { if (shouldAutoExpandManagement) setShowAllActions(true); }, [shouldAutoExpandManagement]); - // Whether there are any user-actionable controls in this modal beyond plain info. - // Falls back to phase-based check when capabilities haven't loaded yet. + // Whether the modal offers anything beyond plain info, for the guided panel + // and the unlock notice. Deliberately looser than `sections.anyLive`, which + // governs the advanced area alone: this one has a phase-based fallback for + // the window before capabilities load. const hasRelevantActions = // Phase-based fallback (used when caps are null/loading) badge.phase === "AVAILABLE" || @@ -331,12 +346,14 @@ export function NameActionsModal({ caps?.taskState === "wonNeedsRegister" || caps?.taskState === "lostNeedsRedeem" || caps?.taskState === "transferPendingFinalize" || - // Owned names have update/transfer/renew/revoke actions - caps?.ownsName === true; + // A registered name has update/transfer/renew/revoke actions. Merely + // leading an auction does not — the owner coin is still a REVEAL. + caps?.nameIsRegistered === true; - // Once THIS wallet has already bid (one bid per wallet per name) there is - // no actionable control left: every auction button is caps-disabled, so - // the advanced toggle would only open an all-disabled menu. Suppress it. + // A wallet that has bid and is waiting for the window has nothing left to + // submit. This no longer reaches the advanced toggle — `sections.anyLive` + // decides that — and survives only to keep the "unlock to sign" notice off a + // modal with nothing to sign. const alreadyBidWaiting = caps?.taskState === "waitingForBidding"; // Whether the modal actually offers something to sign/broadcast right now. @@ -347,27 +364,11 @@ export function NameActionsModal({ // instead of the looser `hasRelevantActions`. const hasSignableActions = hasRelevantActions && !alreadyBidWaiting; - // In BIDDING the advanced section holds only the manual Auction fallbacks - // (Open / Reveal / Redeem) — the name isn't owned yet, so there are no DNS - // or management sections behind the toggle. When every one of those buttons - // is caps-disabled (the common BIDDING case: opening is done, reveal hasn't - // started, nothing to redeem), the toggle would only reveal an all-disabled - // menu. Suppress it unless at least one auction action is actually live. - const advancedHasLiveAction = - badge.phase !== "BIDDING" || - caps?.canOpen?.allowed === true || - caps?.canReveal?.allowed === true || - caps?.canRedeem?.allowed === true; - - // Show the advanced toggle only when there are meaningful extra actions behind it. - const showAdvancedToggle = - hasRelevantActions && - !alreadyBidWaiting && - advancedHasLiveAction && - // Auction-phase advanced actions are always meaningful. - (badge.phase !== "CLOSED" || - // For CLOSED owned names: only show if there are ownership actions the user may want. - caps?.ownsName === true); + // One rule for the toggle: open it only when something behind it can be + // acted on. Every "is this phase meaningful?" special case this used to + // carry is now a section state, so a menu of nothing but upcoming lines + // never gets a button to open it. + const showAdvancedToggle = sections.anyLive; // Use capabilities to determine if an action is disabled and why. const actionDisabled = (_actionKey: string, cap?: NameActionCapability): boolean => { @@ -586,7 +587,16 @@ export function NameActionsModal({ the tests that assert on the phase label follow it. */} {!isLoading && !isError && - (summary ? ( + (summary?.pendingBroadcastAction ? ( + // A transaction of ours for this name is in flight, so the task + // label is a verdict the chain has not reached and an + // instruction the user has already followed. The guided panel + // below says the same thing; the header used to contradict it + // in red. The auctions list already reads this way. + + {pendingBroadcastBadge(summary.pendingBroadcastAction)} + + ) : summary ? ( {summary.label} @@ -687,8 +697,13 @@ export function NameActionsModal({ )} - {/* Ownership indicator — shown when the wallet controls this name */} - {isOwned && ( + {/* Ownership indicator — shown when the wallet genuinely holds the + name. Not `ownsName`: during REVEAL hsd reports the highest + revealer as the owner, and a green "Owned by this wallet" on a + name still being auctioned is the claim a user has least reason to + question. `wonNeedsRegister` counts — the name is held, only the + first resource has yet to be published. */} + {(caps?.nameIsRegistered === true || caps?.taskState === "wonNeedsRegister") && (
)} @@ -852,54 +867,93 @@ export function NameActionsModal({ > {showAllActions ? "Hide advanced actions" - : caps?.ownsName + : // "Manage" is only true once there is a name to manage; on a + // name still being auctioned it promises controls that the + // sections below deliberately do not render. + caps?.nameIsRegistered ? "Manage actions" : "Show all actions"}
)} - {showAllActions && ( -
- {/* Auction actions - always show for all names */} -
-
Auction
-
- - - - - - - - - -
-
+ {/* `anyLive` guards the container as well as the toggle: auto-expand can + leave `showAllActions` true while every section has since gone + absent — a broadcast going out does exactly that — and the bordered + box would render with nothing in it. */} + {showAllActions && sections.anyLive && ( +
+ {sections.auction.kind === "upcoming" && ( + + )} + {sections.auction.kind === "live" && ( +
+
Manual auction actions
+
+ The guided panel above already does this. Use these only if it has fallen out of + step with the chain. +
+ {/* Only what this stage actually allows. Open / Reveal / + Redeem used to all render here, two of them permanently + greyed, which is the wall of dead controls the section + states exist to remove — and at button granularity it is + worse, because a covenant name with no explanation reads as + a thing the user failed to understand. Each live one says + what pressing it does. */} + {caps?.canOpen?.allowed && ( +
+ +
Start the auction for this name.
+
+ )} + {caps?.canReveal?.allowed && ( +
+ +
+ Disclose what you bid. Every bid you placed on this name reveals together. +
+
+ )} + {caps?.canRedeem?.allowed && ( +
+ +
+ {redeemExplainer(caps)} +
+
+ )} +
+ )} - {/* DNS records (REGISTER / UPDATE) - only show for owned names */} - {isOwned && ( + {sections.records.kind === "upcoming" && ( + + )} + {recordsLive && (
DNS records (REGISTER / UPDATE)
@@ -970,22 +1024,28 @@ export function NameActionsModal({ )}
- - - + + + )} )} - {/* Ownership / lifecycle - only show for owned names */} - {isOwned && ( - - run("TRANSFER", () => - build.transfer.mutateAsync({ name, recipient: recipient.trim() }), - ) - } - onFinalize={() => run("FINALIZE", () => build.finalize.mutateAsync({ name }))} - onCancelTransfer={() => run("CANCEL", () => build.cancel.mutateAsync({ name }))} - onRenew={() => run("RENEW", () => build.renew.mutateAsync({ name }))} - onRevoke={() => run("REVOKE", () => build.revoke.mutateAsync({ name }))} - onBuyWithPayment={(paymentAddress, paymentValue) => - run("FINALIZE_WITH_PAYMENT", () => - build.finalizeWithPayment.mutateAsync({ name, paymentAddress, paymentValue }), - ) - } - onSellWithPayment={(buyerAddress, priceValue) => - run("SELL_WITH_PAYMENT", async () => { - // 1. Record the offer for later claim verification. - await build.sellWithPayment.mutateAsync({ - name, - buyerAddress, - priceDoos: priceValue, - }); - // 2. Build the transfer draft to the buyer (normal TRANSFER - // covenant — the payment happens in the buyer's finalize). - return build.transfer.mutateAsync({ name, recipient: buyerAddress }); - }) - } - /> + {sections.ownership.kind === "upcoming" && ( + + )} + {sections.ownership.kind === "live" && ( + <> + + run("TRANSFER", () => + build.transfer.mutateAsync({ name, recipient: recipient.trim() }), + ) + } + onFinalize={() => run("FINALIZE", () => build.finalize.mutateAsync({ name }))} + onCancelTransfer={() => run("CANCEL", () => build.cancel.mutateAsync({ name }))} + onRenew={() => run("RENEW", () => build.renew.mutateAsync({ name }))} + onRevoke={() => run("REVOKE", () => build.revoke.mutateAsync({ name }))} + onBuyWithPayment={(paymentAddress, paymentValue) => + run("FINALIZE_WITH_PAYMENT", () => + build.finalizeWithPayment.mutateAsync({ name, paymentAddress, paymentValue }), + ) + } + onSellWithPayment={(buyerAddress, priceValue) => + run("SELL_WITH_PAYMENT", async () => { + // 1. Record the offer for later claim verification. + await build.sellWithPayment.mutateAsync({ + name, + buyerAddress, + priceDoos: priceValue, + }); + // 2. Build the transfer draft to the buyer (normal TRANSFER + // covenant — the payment happens in the buyer's finalize). + return build.transfer.mutateAsync({ name, recipient: buyerAddress }); + }) + } + /> + {/* Proving ownership belongs to the ownership section, and needs + the same registration: a signature over a name the wallet has + only bid on is a claim every verifier resolves as false. */} + + )} {/* Paid swap claim: shown when a paid_swap_offer exists for this name */} - - {/* Sign message (Task 3) — Namebase-style domain-claim verification, - owned names only; the component itself gates on caps.ownsName. */} -
)} diff --git a/src/components/WalletView.tsx b/src/components/WalletView.tsx index 91d50f91..39a46d43 100644 --- a/src/components/WalletView.tsx +++ b/src/components/WalletView.tsx @@ -26,7 +26,7 @@ import { import { useStartFullSync, useSyncStatus, useCancelFullSync } from "../queries/sync"; import { useNodeLive, useStartHsd } from "../queries/node"; import { useSyncTriggerStore } from "../stores/syncTrigger"; -import { auctionPhase, formatCountdown } from "../lib/auction"; +import { auctionPhase, formatCountdown, taskSummaryFromCapabilities } from "../lib/auction"; import { displayName } from "../lib/idn"; import { NameActionsModal } from "./NameActionsModal"; import { BlockInfoModal } from "./BlockInfoModal"; @@ -113,6 +113,14 @@ export function WalletView() { profile?.id ?? null, ); + // One index over the batch, for the urgency alerts, the batch-action + // eligibility and the Owned Names State column — they must all describe a + // name the same way. + const capsByName = useMemo( + () => new Map(nameCaps.map((c) => [c.name, c])), + [nameCaps], + ); + const startSync = useStartFullSync(); const startHsd = useStartHsd(); const cancelSync = useCancelFullSync(); @@ -293,7 +301,6 @@ export function WalletView() { if (selectedNames.size === 0 || nameCaps.length === 0) { return { canReveal: false, canRedeem: false, canFinalize: false, canTransfer: false }; } - const capsByName = new Map(nameCaps.map((c) => [c.name, c])); let canReveal = true; let canRedeem = true; let canFinalize = true; @@ -969,9 +976,6 @@ export function WalletView() { registerable coin for this name". */} {!isWatchOnly && (() => { - const capsByName = new Map( - nameCaps.map((c) => [c.name, c]), - ); // Countdown fragment for a name's capabilities — honest: when the // backend has no live countdown (e.g. node unreachable/no stats), // this is null and the banner renders WITHOUT a countdown fragment @@ -1254,13 +1258,28 @@ export function WalletView() { - {n.state ? ( - - {auctionPhase(n.state).label} - - ) : ( - "—" - )} + {/* What the name needs, not which auction phase it + is in. The same summary the Auctions list and the + name modal show, so one name is never described + two ways — a row reading "Closed" opened a modal + headed "Won — Register Now". The raw phase is + also nearly constant here: every name you own has + a closed auction. It stays as the fallback for + the window before capabilities load, and for a + watch-only profile that never fetches them. */} + {(() => { + const task = taskSummaryFromCapabilities(capsByName.get(n.name)); + if (task) { + return {task.label}; + } + return n.state ? ( + + {auctionPhase(n.state).label} + + ) : ( + "—" + ); + })()} {n.height ? ( diff --git a/src/components/__tests__/auction-positions.test.tsx b/src/components/__tests__/auction-positions.test.tsx index dfcfbe7c..f3abd67b 100644 --- a/src/components/__tests__/auction-positions.test.tsx +++ b/src/components/__tests__/auction-positions.test.tsx @@ -55,6 +55,10 @@ function baseCaps(name: string, overrides: Record) { phase: "AVAILABLE", taskState: "unavailableOther", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, diff --git a/src/components/__tests__/auction-ux.test.tsx b/src/components/__tests__/auction-ux.test.tsx index 57126e1c..f62c8cb1 100644 --- a/src/components/__tests__/auction-ux.test.tsx +++ b/src/components/__tests__/auction-ux.test.tsx @@ -66,6 +66,10 @@ describe("WalletView — auction UX", () => { phase: "UNKNOWN", taskState: "unavailableOther", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -359,6 +363,10 @@ describe("NameActionsModal — phase header + DNS editor", () => { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -437,6 +445,9 @@ describe("NameActionsModal — phase header + DNS editor", () => { invokeMock.mockImplementation(routeModal(captured)); render( {}} />, { wrapper: wrapper() }); + // Records are optional on the Register step, so the editor sits behind a + // disclosure — whoever does want records up front opens it. + fireEvent.click(await screen.findByTestId("register-dns-toggle")); await screen.findByTestId("dns-rows"); // Default first row is a TXT — fill its value, then Register. fireEvent.change(screen.getByLabelText("record value"), { @@ -470,6 +481,10 @@ describe("NameActionsModal — local bid shown before reveal", () => { phase: "BIDDING", taskState: "waitingForBidding", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: opts.hasBidCommitment, hasRevealCoin: false, hasOwnerCoin: false, @@ -575,6 +590,10 @@ describe("NameActionsModal — recover bid commitment (Task 2 / C2)", () => { phase: "REVEAL", taskState: "unavailableOther", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment, hasRevealCoin: false, hasOwnerCoin: false, diff --git a/src/components/__tests__/auction-view-task.test.tsx b/src/components/__tests__/auction-view-task.test.tsx index 8b32093d..f0dc5835 100644 --- a/src/components/__tests__/auction-view-task.test.tsx +++ b/src/components/__tests__/auction-view-task.test.tsx @@ -55,6 +55,10 @@ function baseCaps(name: string, overrides: Record) { phase: "AVAILABLE", taskState: "availableToOpen", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -101,6 +105,10 @@ const CAPS_BY_NAME: Record> = { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasOwnerCoin: true, canOpen: { allowed: false, reason: "Phase is CLOSED" }, canBid: { allowed: false, reason: "Phase is CLOSED" }, @@ -278,6 +286,10 @@ describe("AuctionsView — task-driven row rendering", () => { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasOwnerCoin: true, canOpen: { allowed: false, reason: "Phase is CLOSED" }, canBid: { allowed: false, reason: "Phase is CLOSED" }, @@ -299,6 +311,10 @@ describe("AuctionsView — task-driven row rendering", () => { phase: "CLOSED", taskState: "ownedNoAction", ownsName: true, + nameIsRegistered: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasOwnerCoin: true, canUpdate: { allowed: true, reason: null }, canTransfer: { allowed: true, reason: null }, diff --git a/src/components/__tests__/bid-form-validation.test.tsx b/src/components/__tests__/bid-form-validation.test.tsx index b7472dad..9263ffaa 100644 --- a/src/components/__tests__/bid-form-validation.test.tsx +++ b/src/components/__tests__/bid-form-validation.test.tsx @@ -38,6 +38,10 @@ function route() { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, diff --git a/src/components/__tests__/name-acquisition.test.tsx b/src/components/__tests__/name-acquisition.test.tsx index e15e56c2..b1a16d08 100644 --- a/src/components/__tests__/name-acquisition.test.tsx +++ b/src/components/__tests__/name-acquisition.test.tsx @@ -414,6 +414,10 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "AVAILABLE", taskState: "waitingForBidding", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, @@ -483,6 +487,10 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, @@ -568,6 +576,10 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "BIDDING", taskState: "waitingForBidding", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: true, hasRevealCoin: false, hasOwnerCoin: false, @@ -650,6 +662,10 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -700,6 +716,10 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -757,6 +777,10 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + nameIsRegistered: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -818,6 +842,13 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + // Registered, but its owner coin has not synced — the sections + // exist (that is the stage), and each button carries its own + // "not synced" reason. + nameIsRegistered: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, diff --git a/src/components/__tests__/name-actions-bid-gate.test.tsx b/src/components/__tests__/name-actions-bid-gate.test.tsx index 0070dbd8..a56d1948 100644 --- a/src/components/__tests__/name-actions-bid-gate.test.tsx +++ b/src/components/__tests__/name-actions-bid-gate.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import type { ReactNode } from "react"; @@ -44,6 +44,10 @@ function route(phase: "OPENING" | "BIDDING", canBidAllowed: boolean) { phase, taskState: "none", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: canBidAllowed ? false : phase === "BIDDING", hasBidCoin: false, hasRevealCoin: false, @@ -127,19 +131,18 @@ function wrapper() { beforeEach(() => invokeMock.mockReset()); describe("NameActionsModal — BidGate hides inputs off the bidding phase", () => { - it("shows no Bid/Lockup inputs in the advanced section during OPENING", async () => { + it("offers no advanced section at all during OPENING, so no bid can be invited", async () => { invokeMock.mockImplementation(route("OPENING", false)); render( {}} />, { wrapper: wrapper(), }); + await screen.findByTestId("name-phase"); - // Open the advanced section where the duplicate bid form used to live. - const toggle = await screen.findByTestId("all-actions-toggle"); - fireEvent.click(toggle); - - // The advanced section shows the manual Open fallback … - expect(await screen.findByRole("button", { name: "Open" })).toBeInTheDocument(); - // … and there is NO Bid / Lockup input to invite a bid next to "Open". + // The auction is already open, so the manual Open fallback is refused and + // nothing else applies to a name this wallet does not own. A toggle onto a + // disabled "Open" is the dead control the section states exist to remove — + // which makes "no Bid / Lockup input here" unconditional. + expect(screen.queryByTestId("all-actions-toggle")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Bid (HNS)")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Lockup (HNS)")).not.toBeInTheDocument(); expect(screen.queryByTestId("bid-gate-placeholder")).not.toBeInTheDocument(); diff --git a/src/components/__tests__/name-actions-dns-prefill.test.tsx b/src/components/__tests__/name-actions-dns-prefill.test.tsx index 0c35bbb2..ab979f72 100644 --- a/src/components/__tests__/name-actions-dns-prefill.test.tsx +++ b/src/components/__tests__/name-actions-dns-prefill.test.tsx @@ -35,6 +35,10 @@ const ownedCaps = { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, diff --git a/src/components/__tests__/name-actions-draft-cleanup.test.tsx b/src/components/__tests__/name-actions-draft-cleanup.test.tsx index 4f7600c9..9a98d526 100644 --- a/src/components/__tests__/name-actions-draft-cleanup.test.tsx +++ b/src/components/__tests__/name-actions-draft-cleanup.test.tsx @@ -43,6 +43,10 @@ function route(overrides: Record Promise> = {}) { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, diff --git a/src/components/__tests__/name-actions-error-handling.test.tsx b/src/components/__tests__/name-actions-error-handling.test.tsx index 672001c8..5420a3d8 100644 --- a/src/components/__tests__/name-actions-error-handling.test.tsx +++ b/src/components/__tests__/name-actions-error-handling.test.tsx @@ -42,6 +42,10 @@ function route(overrides: Record Promise> = {}) { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, diff --git a/src/components/__tests__/name-actions-gating.test.tsx b/src/components/__tests__/name-actions-gating.test.tsx index ac52488d..fcf4a1fb 100644 --- a/src/components/__tests__/name-actions-gating.test.tsx +++ b/src/components/__tests__/name-actions-gating.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import type { ReactNode } from "react"; @@ -44,6 +44,10 @@ function route( phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -114,7 +118,7 @@ function wrapper() { beforeEach(() => invokeMock.mockReset()); describe("NameActionsModal — node-readiness gating", () => { - it("blocks every name action with the reason when the node can't write", async () => { + it("states the reason once and offers no menu when the node can't write", async () => { invokeMock.mockImplementation( route( false, @@ -133,16 +137,15 @@ describe("NameActionsModal — node-readiness gating", () => { expect(screen.getByRole("button", { name: /^Register$/i })).toBeDisabled(); }); - // Advanced actions are behind a toggle — open them to verify gating on actions - // that are always present in the auction section for the current modal contract. - fireEvent.click(screen.getByTestId("all-actions-toggle")); - expect(screen.getAllByRole("button", { name: /^Open$/i }).slice(-1)[0]).toBeDisabled(); - expect(screen.getByRole("button", { name: /^Reveal$/i })).toBeDisabled(); - expect(screen.getByRole("button", { name: /^Redeem$/i })).toBeDisabled(); - // In CLOSED (an owned, registered name) the BidGate hides the Bid / - // Lockup inputs and their submit entirely — there is no meaningful "Bid" - // action for a name whose auction is over. This is a stronger guarantee - // than "disabled" and replaces the earlier assertion. + // A node that cannot write refuses every capability, so there is nothing + // behind the advanced toggle and no toggle to open. The reason is already + // stated twice — on the banner above and on the guided action — and a menu + // of six buttons all carrying that same reason is the wall of dead + // controls this modal no longer renders. + expect(screen.queryByTestId("all-actions-toggle")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Reveal$/i })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Redeem$/i })).toBeNull(); + // No meaningful "Bid" action either, for a name whose auction is over. expect(screen.queryByRole("button", { name: /^Bid$/i })).toBeNull(); expect(screen.queryByLabelText("Bid (HNS)")).not.toBeInTheDocument(); // Close stays available. diff --git a/src/components/__tests__/name-modal-sections.test.tsx b/src/components/__tests__/name-modal-sections.test.tsx new file mode 100644 index 00000000..d75e7685 --- /dev/null +++ b/src/components/__tests__/name-modal-sections.test.tsx @@ -0,0 +1,473 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import type { ReactNode } from "react"; + +// The modal's advanced area is a map of the name's lifecycle, not a catalogue +// of the wallet's verbs: a section is live, still ahead (one muted line), or +// not there at all. These drive it through the stages where the old flat +// `ownsName` filter got it wrong. + +const invokeMock = vi.fn(); +vi.mock("../../lib/invoke", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn(), save: vi.fn() })); +vi.mock("@tauri-apps/plugin-fs", () => ({ readTextFile: vi.fn(), writeTextFile: vi.fn() })); +vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({ + writeText: vi.fn(), + readText: vi.fn().mockResolvedValue(""), +})); + +import { NameActionsModal } from "../NameActionsModal"; + +const profile = { + id: "p1", + label: "Primary", + kind: "mnemonic_hot", + network: "regtest", + accountXpub: "xpubFAKE", + accountIndex: 0, + receiveDepth: 20, + changeDepth: 20, + receiveAddress: "rs1qexamplereceiveaddr", + lastSyncedHeight: 10, + lastSyncedAt: null, + watchOnly: false, + hasPassphrase: true, + active: true, +}; + +const no = { allowed: false, reason: "not now" }; +const ok = { allowed: true, reason: null }; + +function capsFor(over: Record) { + return { + name: "n", + phase: "CLOSED", + taskState: "unavailableOther", + ownsName: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + nameIsRegistered: false, + hasBidCommitment: false, + hasBidCoin: false, + hasRevealCoin: false, + hasOwnerCoin: false, + revealTxid: null, + bidValueDoos: null, + lockupValueDoos: null, + myBidCount: 0, + canOpen: no, + canBid: no, + canReveal: no, + canRedeem: no, + canRegister: no, + canUpdate: no, + canTransfer: no, + canFinalize: no, + canCancelTransfer: no, + canRenew: no, + canRevoke: no, + nextActionKey: null, + nextActionLabel: null, + nextActionReason: null, + countdownLabel: null, + countdownBlocks: null, + countdownHours: null, + ...over, + }; +} + +function route(nameInfo: Record, capabilities: Record) { + return (cmd: string) => { + switch (cmd) { + case "get_name_action_capabilities": + return Promise.resolve(capabilities); + case "list_wallet_profiles": + return Promise.resolve([profile]); + case "get_signer_session": + return Promise.resolve({ + walletProfileId: profile.id, + unlocked: true, + unlockedUntilEpochMs: Date.now() + 60000, + }); + case "get_write_capability": + return Promise.resolve({ + signerUnlocked: true, + broadcasterAvailable: true, + canWrite: true, + reason: null, + }); + case "read_name_info": + return Promise.resolve(nameInfo); + default: + return Promise.resolve(null); + } + }; +} + +function wrapper() { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +beforeEach(() => invokeMock.mockReset()); + +describe("NameActionsModal — sections while the auction is still running", () => { + const revealInfo = { + name: "leadingname", + state: "REVEAL", + height: 100, + renewal: null, + owner: { hash: profile.receiveAddress, index: 0 }, + value: null, + highest: 5_000_000, + stats: { blocksUntilClose: 20 }, + }; + const leaderCaps = capsFor({ + name: "leadingname", + phase: "REVEAL", + taskState: "revealDoneWaitingForClose", + // hsd names the highest revealer as owner long before anyone has won. + ownsName: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + nameIsRegistered: false, + hasOwnerCoin: true, + hasRevealCoin: true, + }); + + it("offers no records, ownership or signing on a name it has only bid on", async () => { + invokeMock.mockImplementation(route(revealInfo, leaderCaps)); + render( {}} />, { + wrapper: wrapper(), + }); + + await screen.findByTestId("name-phase"); + await waitFor(() => expect(invokeMock).toHaveBeenCalled()); + + expect(screen.queryByTestId("dns-advanced-toggle")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel transfer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Renew" })).not.toBeInTheDocument(); + expect(screen.queryByText(/Sign message for/)).not.toBeInTheDocument(); + }); + + // The upcoming state is only worth anything if it reaches the screen. It + // does so beside a live section — here the reveal the wallet still owes — + // which is the common shape of this stage, not an edge case. + it("names what records and ownership are waiting for, beside the live reveal", async () => { + invokeMock.mockImplementation(route(revealInfo, { ...leaderCaps, canReveal: ok })); + render( {}} />, { + wrapper: wrapper(), + }); + + fireEvent.click(await screen.findByTestId("all-actions-toggle")); + // Both records and ownership are waiting on the same thing, and both say so. + expect(screen.getAllByText(/after you register this name/)).toHaveLength(2); + expect(screen.getByText("DNS records")).toBeInTheDocument(); + expect(screen.getByText("Ownership")).toBeInTheDocument(); + expect(screen.getByText("Manual auction actions")).toBeInTheDocument(); + }); + + it("does not call the menu 'Manage actions' when there is nothing to manage", async () => { + invokeMock.mockImplementation(route(revealInfo, { ...leaderCaps, canReveal: ok })); + render( {}} />, { + wrapper: wrapper(), + }); + + const toggle = await screen.findByTestId("all-actions-toggle"); + expect(toggle).toHaveTextContent("Show all actions"); + }); + + // The green badge reads the same wrong flag the sections used to. Leading an + // auction is not holding the name, and saying so in the modal's most + // confident-looking element is the claim the user has least reason to doubt. + it("does not claim the wallet owns a name it is only leading", async () => { + invokeMock.mockImplementation(route(revealInfo, leaderCaps)); + render( {}} />, { + wrapper: wrapper(), + }); + await screen.findByTestId("name-phase"); + + expect(screen.queryByTestId("ownership-indicator")).not.toBeInTheDocument(); + }); + + // The read-only DNS block in NameDetails is suppressed only when the + // editable section has taken the records over. Those two gates have to move + // together: point the read-only one at `ownsName` again and DNS vanishes + // from this stage entirely — hidden here, and not rendered there either. + it("still shows the read-only DNS block the editor is not taking over", async () => { + invokeMock.mockImplementation(route(revealInfo, leaderCaps)); + render( {}} />, { + wrapper: wrapper(), + }); + await screen.findByTestId("name-phase"); + + expect(await screen.findByText("DNS Records")).toBeInTheDocument(); + expect(screen.queryByTestId("dns-advanced-toggle")).not.toBeInTheDocument(); + }); +}); + +describe("NameActionsModal — sections while a broadcast waits for a block", () => { + it("closes the advanced menu entirely", async () => { + invokeMock.mockImplementation( + route( + { + name: "pendingname", + state: "CLOSED", + height: 100, + renewal: 200, + owner: { hash: profile.receiveAddress, index: 0 }, + registered: true, + value: 1_000_000, + highest: 2_000_000, + stats: { blocksUntilExpire: 100 }, + }, + capsFor({ + name: "pendingname", + taskState: "ownedNoUrgentAction", + ownsName: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + nameIsRegistered: true, + hasOwnerCoin: true, + canUpdate: ok, + canRenew: ok, + pendingBroadcastAction: "update", + }), + ), + ); + render( {}} />, { + wrapper: wrapper(), + }); + await screen.findByTestId("name-phase"); + await waitFor(() => expect(invokeMock).toHaveBeenCalled()); + + expect(screen.queryByTestId("all-actions-toggle")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Renew" })).not.toBeInTheDocument(); + // Auto-expand still fires for a registered name, so without its own guard + // the container renders as an empty bordered box holding nothing. + expect(screen.queryByTestId("advanced-actions")).not.toBeInTheDocument(); + }); +}); + +describe("NameActionsModal — sections on a name the wallet really owns", () => { + it("keeps records, ownership and signing available", async () => { + invokeMock.mockImplementation( + route( + { + name: "ownedname", + state: "CLOSED", + height: 100, + renewal: 200, + owner: { hash: profile.receiveAddress, index: 0 }, + registered: true, + value: 1_000_000, + highest: 2_000_000, + stats: { blocksUntilExpire: 100 }, + }, + capsFor({ + name: "ownedname", + taskState: "ownedNoUrgentAction", + ownsName: true, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + nameIsRegistered: true, + hasOwnerCoin: true, + canUpdate: ok, + canTransfer: ok, + canRenew: ok, + canRevoke: ok, + }), + ), + ); + render( {}} />, { wrapper: wrapper() }); + await screen.findByTestId("name-phase"); + + expect(await screen.findByTestId("dns-advanced-toggle")).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Renew" })).toBeInTheDocument(); + expect(await screen.findByText(/Sign message for/)).toBeInTheDocument(); + }); +}); + +describe("NameActionsModal — the Register step", () => { + const wonInfo = { + name: "wonname", + state: "CLOSED", + height: 100, + renewal: 200, + owner: { hash: profile.receiveAddress, index: 0 }, + registered: false, + value: 12_000_000, + highest: 1_000_000_000, + stats: { blocksUntilExpire: 4979, daysUntilExpire: 34.5 }, + }; + const wonCaps = capsFor({ + name: "wonname", + taskState: "wonNeedsRegister", + ownsName: true, + nameIsRegistered: false, + hasOwnerCoin: true, + hasRevealCoin: true, + canRegister: ok, + nextActionKey: "REGISTER", + nextActionLabel: "Register Name", + }); + + // Reported from a live wallet: the Register panel dropped a DNS record + // editor in front of the user with nothing said about it, so the obvious + // reading was that records are required to register. They are not — hsd + // caps the resource size and accepts an empty one — and the wallet already + // sends an empty resource when the editor is untouched. The panel has to + // say so, or the user stalls on a question the chain does not ask. + it("says records are optional and keeps the editor out of the way", async () => { + invokeMock.mockImplementation(route(wonInfo, wonCaps)); + render( {}} />, { wrapper: wrapper() }); + + await screen.findByText("Register Name"); + expect(screen.getByText(/DNS records are optional/i)).toBeInTheDocument(); + // Not in the way: no rows until the user asks for them. + expect(screen.queryByTestId("dns-rows")).not.toBeInTheDocument(); + // And Register is reachable without touching them. + expect(screen.getByRole("button", { name: "Register" })).toBeEnabled(); + }); + + it("opens the editor for whoever does want records up front", async () => { + invokeMock.mockImplementation(route(wonInfo, wonCaps)); + render( {}} />, { wrapper: wrapper() }); + + fireEvent.click(await screen.findByTestId("register-dns-toggle")); + expect(await screen.findByTestId("dns-rows")).toBeInTheDocument(); + }); +}); + +describe("NameActionsModal — the Register step, laid out", () => { + const wonRedeemInfo = { + name: "wonredeem", + state: "CLOSED", + height: 100, + renewal: 200, + owner: { hash: profile.receiveAddress, index: 0 }, + registered: false, + value: 12_000_000, + highest: 1_000_000_000, + stats: { blocksUntilExpire: 4979, daysUntilExpire: 34.5 }, + }; + // Won the name AND holding losing reveals on it — what outbidding yourself + // leaves behind, and the state a live wallet was actually in. + const wonRedeemCaps = capsFor({ + name: "wonredeem", + taskState: "wonNeedsRegister", + ownsName: true, + nameIsRegistered: false, + hasOwnerCoin: true, + hasRevealCoin: true, + canRegister: ok, + canRedeem: ok, + redeemableRevealCount: 3, + redeemableValueDoos: 28_000_000, + nextActionLabel: "Register Name", + }); + + async function openAdvanced() { + invokeMock.mockImplementation(route(wonRedeemInfo, wonRedeemCaps)); + render( {}} />, { wrapper: wrapper() }); + await screen.findByText("Register Name"); + const toggle = screen.queryByTestId("all-actions-toggle"); + if (toggle) fireEvent.click(toggle); + } + + // The disclosure was a bare inline
- + {/* Registering publishes the name's resource, and an empty one + is valid — hsd caps the resource size and nothing requires + it to be non-empty. Putting a record editor in front of the + user with nothing said about it read as "records required", + which is a question the chain never asks. Say it is optional + and keep the editor behind a disclosure, so the ordinary path + is one button. */} +
+ Registering claims the name on-chain. DNS records are optional — you can register + now and publish records later with Update. +
+ {showRegisterDns ? ( + + ) : ( + // Block wrapper on purpose: a bare inline +
+ )}