From cabf3165d61902d2e2587ff2c85c1380eceaab4f Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:12:57 +0200 Subject: [PATCH 01/17] fix(sign): a reveal is not a name you can sign for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sign_name_message` resolves the signing key through `get_name_coin`, which joins whatever `tracked_name_states.owner_txid` points at and filters on no covenant at all. During REVEAL hsd already reports the highest revealer as the owner, so for a wallet leading its own auction that row points at its own REVEAL coin — and the command signed it, returning a well-formed claim of ownership for a name nobody had won yet. Any verifier resolving the name's real owner reads that claim as false, with nothing to explain why. It now requires the owner coin to be REGISTER or later, the same rule the ownership capabilities use, and says "is not registered yet". The happy-path fixture seeded `covenant_type = 4` — COV_REVEAL — so the one test that proved signing works was proving it on a name that is not owned. The seed now takes the covenant explicitly and defaults to REGISTER, which is what a signable name actually looks like. --- src-tauri/src/commands/tx.rs | 11 +++++ .../src/tests/sign_name_message_tests.rs | 42 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) 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/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(); From 3cae0382c2e1495301ea9c9b843fba6146e14d10 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:13:47 +0200 Subject: [PATCH 02/17] feat(names): report whether a name is actually registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ownsName` is not "this wallet controls the name": during REVEAL `getnameinfo` names the highest revealer as the owner, so a wallet merely leading its own auction reads as an owner while holding nothing but a REVEAL coin. The capability gates already know the difference — they compute it to decide Update, Transfer, Renew and Revoke — but the frontend had no way to ask, and re-derived it from `ownsName`. That wrong answer is what put DNS records, Ownership and Sign message on screen for a name the wallet had not won, and auto-expanded the section so it appeared without a click. The backend now states it once, as `nameIsRegistered`, so there is one answer to read rather than two to keep in step. The conservative fallback reports false: with no node-synced owner coin nothing proves the name is registered. --- src-tauri/src/commands/names.rs | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index cd2e1b84..72f8adf5 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -348,6 +348,14 @@ 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, pub has_bid_commitment: bool, pub has_bid_coin: bool, pub has_reveal_coin: bool, @@ -1187,6 +1195,7 @@ pub(crate) fn build_name_action_capabilities( phase, task_state, owns_name, + name_is_registered, has_bid_commitment: action_ctx.has_bid_commitment, has_bid_coin: action_ctx.has_bid_coin, has_reveal_coin: action_ctx.has_reveal_coin, @@ -1231,6 +1240,10 @@ 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, has_bid_commitment: false, has_bid_coin: false, has_reveal_coin: false, @@ -4413,6 +4426,72 @@ 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 { From 3990522b206bd5f74f172781d41b59ead9034bf7 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:13:58 +0200 Subject: [PATCH 03/17] fix(names): the two transfer capabilities were wrong in opposite ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update was offered during a pending transfer, and Cancel transfer was offered when there was no transfer at all. Update is the dangerous one. hsd lets a TRANSFER coin go to UPDATE, RENEW, FINALIZE or REVOKE (`rules.verifyCovenants`), and that UPDATE branch *is* how a transfer is cancelled — so the node accepts the transaction and the pending transfer quietly disappears. A button labelled "edit your DNS records" was a way to lose a transfer in flight, with nothing on screen naming transfers at all. It is now refused while a transfer is pending, and says so; cancelling stays available under its own name, which is the button that means it. Cancel transfer is the cheap one: `can_finalize` has always required `transfer_has_items` and its sibling did not, so on an ordinary registered name it was live and built an UPDATE that changes nothing and costs a fee. One fixture asserted both old rules while describing two states at once — an owned name that was also mid-transfer. It is now the ordinary owned name, and the pending-transfer state has its own case. --- src-tauri/src/commands/names.rs | 113 +++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 72f8adf5..f3096d15 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -23,7 +23,7 @@ use crate::noncustodial::network::Network; use crate::noncustodial::node_rpc::NodeRpc; use crate::noncustodial::rpc::NodeRpcClient; use crate::noncustodial::send::{self, SpendableCoin}; -use crate::noncustodial::sync::{self, COV_REGISTER, COV_REVEAL}; +use crate::noncustodial::sync::{self, COV_REGISTER, COV_REVEAL, COV_TRANSFER}; use crate::noncustodial::tx::sighash; use crate::noncustodial::types::TxDraftSummary; use crate::noncustodial::{address, bids, covenants, names, resource}; @@ -1025,13 +1025,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 }, @@ -1059,12 +1069,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 }, @@ -4744,8 +4759,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, @@ -4767,15 +4787,90 @@ 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") + ); } #[test] From ab79c00e81ac5415e5a3e4fdda2cac9f0432e909 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:17:32 +0200 Subject: [PATCH 04/17] feat(names): decide the modal's sections in one pure place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The name-actions modal ran two organising principles at once: a guided panel that knows the stage, and a flat catalogue of every verb the wallet has, filtered by `ownsName` alone. The catalogue had no idea what stage the name was in, so it relied on disabled buttons to explain — and its one filter is true during REVEAL for a wallet leading its own auction, which is how DNS records, Ownership and Sign message reached the screen on a name nobody had won. `resolveSections` gives each section one of three states instead. Live when something inside is allowed. Upcoming — one muted line naming what unlocks it, no controls — when it belongs to a later stage. Absent when it cannot apply at all. That keeps the phase order visible, which is why the sections were left on screen in the first place, without leaving a dead control per verb. It is pure, so the stage matrix is a table of unit tests rather than nine renders of a thousand-line modal. Three rules are worth naming: Register lives in the records section, so that section opens before the name is registered; a pending transfer closes it again, because hsd accepts TRANSFER -> UPDATE and that transition is the cancel; and while a broadcast waits for a block every section is absent, since offering alternatives then only invites a competing transaction. --- src/lib/nameSections.test.ts | 172 +++++++++++++++++++++++++++++++++++ src/lib/nameSections.ts | 100 ++++++++++++++++++++ src/types/index.ts | 9 ++ 3 files changed, 281 insertions(+) create mode 100644 src/lib/nameSections.test.ts create mode 100644 src/lib/nameSections.ts diff --git a/src/lib/nameSections.test.ts b/src/lib/nameSections.test.ts new file mode 100644 index 00000000..405af3eb --- /dev/null +++ b/src/lib/nameSections.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect } from "vitest"; +import { resolveSections } from "./nameSections"; +import type { NameActionCapabilities, NameActionCapability } from "../types"; + +const no = (reason: string): NameActionCapability => ({ allowed: false, reason }); +const yes: NameActionCapability = { allowed: true, reason: null }; + +/** A capabilities object with everything refused; each test says what it allows. */ +function caps(over: Partial = {}): NameActionCapabilities { + const denied = no("not now"); + return { + name: "example", + phase: "CLOSED", + taskState: "unavailableOther", + ownsName: false, + nameIsRegistered: false, + hasBidCommitment: false, + hasBidCoin: false, + hasRevealCoin: false, + hasOwnerCoin: false, + revealTxid: null, + bidValueDoos: null, + lockupValueDoos: null, + myBidCount: 0, + canOpen: denied, + canBid: denied, + canReveal: denied, + canRedeem: denied, + canRegister: denied, + canUpdate: denied, + canTransfer: denied, + canFinalize: denied, + canCancelTransfer: denied, + canRenew: denied, + canRevoke: denied, + nextActionKey: null, + nextActionLabel: null, + nextActionReason: null, + countdownLabel: null, + countdownBlocks: null, + countdownHours: null, + ...over, + }; +} + +describe("resolveSections — leading your own auction is not owning the name", () => { + // The case the whole three-state split exists for. During REVEAL hsd reports + // the highest revealer as the owner, so `ownsName` is true while the wallet + // holds nothing but a REVEAL coin. Gating on it put DNS records, Ownership + // and Sign message on screen — and auto-expanded them — for a name that had + // not been won. + const revealLeader = caps({ + phase: "REVEAL", + taskState: "revealDoneWaitingForClose", + ownsName: true, + nameIsRegistered: false, + hasRevealCoin: true, + }); + + it("holds records and ownership back, naming what unlocks them", () => { + const s = resolveSections(revealLeader); + expect(s.records.kind).toBe("upcoming"); + expect(s.ownership.kind).toBe("upcoming"); + if (s.records.kind === "upcoming") expect(s.records.when).toMatch(/register/i); + if (s.ownership.kind === "upcoming") expect(s.ownership.when).toMatch(/register/i); + }); + + it("reports nothing live, so the modal has no advanced menu to open", () => { + expect(resolveSections(revealLeader).anyLive).toBe(false); + }); + + it("opens the auction section as soon as there is a reveal to send", () => { + const s = resolveSections(caps({ ...revealLeader, canReveal: yes })); + expect(s.auction.kind).toBe("live"); + expect(s.anyLive).toBe(true); + }); +}); + +describe("resolveSections — while a transaction is waiting for a block", () => { + // Between broadcast and the block the chain still reports the previous + // state, and the honest answer is "nothing to do". A second menu offering + // alternatives invites the user to send a competing transaction. + it("offers nothing at all, whatever the phase says", () => { + const s = resolveSections( + caps({ + phase: "CLOSED", + taskState: "ownedNoUrgentAction", + ownsName: true, + nameIsRegistered: true, + canUpdate: yes, + canRenew: yes, + pendingBroadcastAction: "update", + }), + ); + expect(s.auction.kind).toBe("absent"); + expect(s.records.kind).toBe("absent"); + expect(s.ownership.kind).toBe("absent"); + expect(s.anyLive).toBe(false); + }); +}); + +describe("resolveSections — records", () => { + // Register lives inside the records section: it publishes the first + // resource. Gating the section on "already registered" would take away the + // only button the just-won stage has. + it("is live on a won name that still needs registering", () => { + const s = resolveSections( + caps({ + taskState: "wonNeedsRegister", + ownsName: true, + nameIsRegistered: false, + canRegister: yes, + }), + ); + expect(s.records.kind).toBe("live"); + expect(s.anyLive).toBe(true); + // Transfer, Renew and the rest still need a REGISTER to have happened. + expect(s.ownership.kind).toBe("upcoming"); + }); + + // hsd accepts TRANSFER -> UPDATE and that transition IS the cancel, so the + // Update button would end the transfer while saying nothing about it. + it("steps aside while a transfer is pending, and points at the button that means it", () => { + const s = resolveSections( + caps({ + phase: "TRANSFER", + taskState: "transferPendingFinalize", + ownsName: true, + nameIsRegistered: true, + canFinalize: yes, + canCancelTransfer: yes, + }), + ); + expect(s.records.kind).toBe("upcoming"); + if (s.records.kind === "upcoming") expect(s.records.when).toMatch(/cancel/i); + expect(s.ownership.kind).toBe("live"); + }); + + it("is absent on a name this wallet has nothing to do with", () => { + const s = resolveSections(caps({ phase: "BIDDING", ownsName: false })); + expect(s.records.kind).toBe("absent"); + expect(s.ownership.kind).toBe("absent"); + }); +}); + +describe("resolveSections — auction", () => { + // On a registered name with nothing left to redeem the auction is history. + // Keeping a fallback for Open/Reveal/Redeem there is noise on every managed + // name the wallet holds. + it("is gone once the name is registered and no auction step remains", () => { + const s = resolveSections( + caps({ + taskState: "ownedNoUrgentAction", + ownsName: true, + nameIsRegistered: true, + canUpdate: yes, + }), + ); + expect(s.auction.kind).toBe("absent"); + expect(s.anyLive).toBe(true); // records + ownership carry the name now + }); + + // A losing bidder still has a redeem to make on a name that is not theirs. + it("stays live for a redeem on a name the wallet does not own", () => { + const s = resolveSections( + caps({ taskState: "lostNeedsRedeem", ownsName: false, canRedeem: yes }), + ); + expect(s.auction.kind).toBe("live"); + expect(s.records.kind).toBe("absent"); + expect(s.anyLive).toBe(true); + }); +}); diff --git a/src/lib/nameSections.ts b/src/lib/nameSections.ts new file mode 100644 index 00000000..56d75072 --- /dev/null +++ b/src/lib/nameSections.ts @@ -0,0 +1,100 @@ +// Which sections of the name-actions modal exist right now, and which are +// still ahead. +// +// The modal used to render a flat catalogue of everything the wallet can do, +// filtered by one flag (`ownsName`). That flag is true during REVEAL for a +// wallet leading its own auction, so a name that had not been won showed DNS +// records, Ownership and Sign message — and auto-expanded them. The reverse +// failure is just as bad: hiding a section entirely reads as a missing +// feature rather than a later step. +// +// So a section is one of three things, and this module is the only place that +// decides which. It is pure, so the whole stage matrix is testable without +// rendering a 1000-line modal. + +import type { NameActionCapabilities } from "../types"; + +export type SectionState = + /** At least one action inside is allowed. Rendered open, with its controls. */ + | { kind: "live" } + /** A later stage. One muted line naming what unlocks it — no controls. */ + | { kind: "upcoming"; when: string } + /** Cannot apply to this name for this wallet. Not rendered at all. */ + | { kind: "absent" }; + +export interface ModalSections { + /** Manual Open / Reveal / Redeem — the fallback for when the guided panel + * has fallen out of step with the chain. */ + auction: SectionState; + /** DNS records: REGISTER and UPDATE. */ + records: SectionState; + /** Transfer / Finalize / Cancel / Renew / Revoke, and signing for the name. */ + ownership: SectionState; + /** Whether anything at all is actionable — drives the advanced toggle. */ + anyLive: boolean; +} + +const NOT_REGISTERED = "after you register this name"; + +export function resolveSections(caps: NameActionCapabilities | null | undefined): ModalSections { + // Between broadcast and the block the chain still reports the previous + // state, so every phase-derived section would describe a world where the + // user never pressed the button. The honest answer is that there is nothing + // to do, and a menu of alternatives here only invites a competing + // transaction. The guided panel says what is in flight. + if (caps?.pendingBroadcastAction) { + return { + auction: { kind: "absent" }, + records: { kind: "absent" }, + ownership: { kind: "absent" }, + anyLive: false, + }; + } + + const registered = caps?.nameIsRegistered === true; + const ours = caps?.ownsName === true; + + const auctionLive = + caps?.canOpen.allowed === true || + caps?.canReveal.allowed === true || + caps?.canRedeem.allowed === true; + + // Once the name is registered the auction is history — a standing fallback + // for Open / Reveal / Redeem is noise on every name the wallet manages. + // A redeem left over keeps it live, because that IS an auction step. + const auction: SectionState = auctionLive + ? { kind: "live" } + : registered + ? { kind: "absent" } + : { kind: "upcoming", when: "when this name's auction needs a step from you" }; + + // Register publishes the first resource, so it belongs to this section — + // which means the just-won stage needs it open before the name is + // registered. A pending transfer takes it away again: hsd accepts + // TRANSFER -> UPDATE and that transition is the cancel, so Update here would + // end the transfer while saying nothing about transfers. + const transferPending = caps?.taskState === "transferPendingFinalize"; + const records: SectionState = !ours + ? { kind: "absent" } + : transferPending + ? { + kind: "upcoming", + when: "after the transfer settles — editing records now would cancel it, which Cancel transfer does on purpose", + } + : registered || caps?.canRegister.allowed === true + ? { kind: "live" } + : { kind: "upcoming", when: NOT_REGISTERED }; + + const ownership: SectionState = !ours + ? { kind: "absent" } + : registered + ? { kind: "live" } + : { kind: "upcoming", when: NOT_REGISTERED }; + + return { + auction, + records, + ownership, + anyLive: [auction, records, ownership].some((s) => s.kind === "live"), + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 90064f18..989fc05e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -605,6 +605,15 @@ export interface NameActionCapabilities { phase: string; taskState: AuctionTaskState; ownsName: boolean; + /** + * Whether the name is actually REGISTERED — an owner coin at COV_REGISTER or + * later. `ownsName` is NOT this: during REVEAL hsd already reports the + * highest revealer as the owner, so a wallet merely leading its own auction + * reads as an owner while holding nothing but a REVEAL coin. The backend + * states it so the UI stops re-deriving a wrong answer. Optional so existing + * fixtures stay valid; absent is read as "not registered". + */ + nameIsRegistered?: boolean; hasBidCommitment: boolean; /** Unspent COV_BID coin for this name — what a REVEAL actually spends. * Gates `canReveal`. Backend fix (Task 6 / I2 Part 3): `hasRevealCoin` From 05758606c755977a391a6daffdc4abee2a6861de Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:27:31 +0200 Subject: [PATCH 05/17] fix(name-modal): show the sections this stage actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advanced area now renders what `resolveSections` says exists, and every gate that used to read `ownsName` reads the stage instead. On a name the wallet is only leading — the case that prompted this — DNS records, Ownership and Sign message are gone, replaced by one muted line each saying they arrive after the name is registered. The section was not even being opened by hand: auto-expand read the same flag, so it unfolded itself. The green "Owned by this wallet" badge read it too, and claimed ownership of a name still being auctioned. Signing moves inside Ownership, where proving ownership belongs, and needs the same registration the backend now requires of it. The auction section says what it is — a manual fallback for when the guided panel has fallen out of step — instead of standing there unexplained. While a broadcast waits for a block nothing is offered at all. One rule now decides the toggle: open it only when something behind it can be acted on. That drops three phase special cases, and drops the menu in two places it used to appear empty — during OPENING, whose only entry was a disabled Open, and behind a node that cannot write, where every button carried the same reason the banner above already gives. Two tests asserted those menus; they now assert their absence. The editable records section owns the DNS read, the freshness gate and the one-shot seeding, so none of them can drift from what is on screen. --- src/components/NameActionsModal.tsx | 244 ++++++++-------- .../__tests__/name-acquisition.test.tsx | 5 + .../__tests__/name-actions-bid-gate.test.tsx | 17 +- .../name-actions-dns-prefill.test.tsx | 1 + .../__tests__/name-actions-gating.test.tsx | 21 +- .../__tests__/name-modal-sections.test.tsx | 274 ++++++++++++++++++ .../name-actions/NameSignMessage.tsx | 10 +- .../name-actions/UpcomingSection.tsx | 15 + .../__tests__/name-sign-message.test.tsx | 2 + 9 files changed, 452 insertions(+), 137 deletions(-) create mode 100644 src/components/__tests__/name-modal-sections.test.tsx create mode 100644 src/components/name-actions/UpcomingSection.tsx diff --git a/src/components/NameActionsModal.tsx b/src/components/NameActionsModal.tsx index 1752cb85..e122a24c 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"; @@ -175,7 +177,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 +212,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 +230,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 +264,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 +282,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,13 +318,13 @@ 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(() => { @@ -331,8 +342,9 @@ 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 @@ -347,27 +359,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 => { @@ -687,8 +683,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,7 +853,10 @@ 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"} @@ -861,45 +865,55 @@ export function NameActionsModal({ {showAllActions && (
- {/* Auction actions - always show for all names */} -
-
Auction
-
- - - - - - - - - -
-
+ {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. +
+
+ + + + + + + + + +
+
+ )} - {/* DNS records (REGISTER / UPDATE) - only show for owned names */} - {isOwned && ( + {sections.records.kind === "upcoming" && ( + + )} + {recordsLive && (
DNS records (REGISTER / UPDATE)
@@ -1005,51 +1019,55 @@ 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/__tests__/name-acquisition.test.tsx b/src/components/__tests__/name-acquisition.test.tsx index e15e56c2..8ef0fff3 100644 --- a/src/components/__tests__/name-acquisition.test.tsx +++ b/src/components/__tests__/name-acquisition.test.tsx @@ -700,6 +700,7 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -818,6 +819,10 @@ 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, 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..f94ac5c0 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"; @@ -127,19 +127,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..4e97c6fa 100644 --- a/src/components/__tests__/name-actions-dns-prefill.test.tsx +++ b/src/components/__tests__/name-actions-dns-prefill.test.tsx @@ -35,6 +35,7 @@ const ownedCaps = { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, diff --git a/src/components/__tests__/name-actions-gating.test.tsx b/src/components/__tests__/name-actions-gating.test.tsx index ac52488d..e22064d1 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"; @@ -133,16 +133,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..df19563d --- /dev/null +++ b/src/components/__tests__/name-modal-sections.test.tsx @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import "@testing-library/jest-dom"; +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"; + +// 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, + 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, + 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(); + }); + + 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, + 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(); + }); +}); + +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, + 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(); + }); +}); diff --git a/src/components/name-actions/NameSignMessage.tsx b/src/components/name-actions/NameSignMessage.tsx index feffabcf..67f63e17 100644 --- a/src/components/name-actions/NameSignMessage.tsx +++ b/src/components/name-actions/NameSignMessage.tsx @@ -13,8 +13,10 @@ import type { NameActionCapabilities, NameSignature } from "../../types"; * verification flow asks for (paste an exact message, sign it, paste the * signature back). * - * Owner-only: renders nothing unless `caps.ownsName` — signing is meaningless - * (and the backend would reject it) for a name this wallet doesn't hold. + * Registered-only: renders nothing unless `caps.nameIsRegistered`. `ownsName` + * is not the same question — during REVEAL hsd reports the highest revealer as + * the owner, and a signature over a name nobody has won yet is a claim every + * verifier resolves as false. The backend refuses it too. * The RAW name is sent to the backend; only the heading/placeholder render * through `displayName` for IDN labels. */ @@ -35,7 +37,7 @@ export function NameSignMessage({ const [showDetails, setShowDetails] = useState(false); const [copied, setCopied] = useState<"signature" | "publicKey" | "address" | null>(null); - if (!caps?.ownsName) return null; + if (!caps?.nameIsRegistered) return null; const handleSign = async () => { setError(null); @@ -55,7 +57,7 @@ export function NameSignMessage({ return (
-
Sign message for .{decoded}
+
Sign message for .{decoded}

Paste the exact text a third party (e.g. Namebase) gave you to verify ownership of this name, then Sign with your wallet key. diff --git a/src/components/name-actions/UpcomingSection.tsx b/src/components/name-actions/UpcomingSection.tsx new file mode 100644 index 00000000..a9a30086 --- /dev/null +++ b/src/components/name-actions/UpcomingSection.tsx @@ -0,0 +1,15 @@ +/** + * A section of the name-actions modal that belongs to a later stage. + * + * One muted line, no controls. The heading stays so the order of the + * lifecycle is still visible — that was the whole argument for leaving these + * sections on screen — while the buttons go, because a wall of disabled + * controls is what made a user ask whether any of them were real. + */ +export function UpcomingSection({ title, when }: { title: string; when: string }) { + return ( +

+ {title} — {when} +
+ ); +} diff --git a/src/components/name-actions/__tests__/name-sign-message.test.tsx b/src/components/name-actions/__tests__/name-sign-message.test.tsx index 00d8d463..9c361856 100644 --- a/src/components/name-actions/__tests__/name-sign-message.test.tsx +++ b/src/components/name-actions/__tests__/name-sign-message.test.tsx @@ -35,6 +35,8 @@ function capsFor(name: string, ownsName: boolean): NameActionCapabilities { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName, + // Signing proves ownership, which does not exist before REGISTER. + nameIsRegistered: ownsName, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, From 8d44e1908761da134a3bb4e18f497d1b8284387e Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:29:09 +0200 Subject: [PATCH 06/17] docs: record the lifecycle section map, and flip the Sign message rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec said `Sign message` was deliberately not gated on registration, on the grounds that signing is offline and builds no transaction. That reasoning was wrong about what a signature over a name *is*: a claim to own it. Until REGISTER there is nothing to claim, and the wallet was emitting a well-formed one anyway. R11 now covers it, R11b covers the two transfer capabilities, and R19 states the three-state section map. Section 5 loses its accepted gap about sections rendering disabled. The two options weighed there — show them disabled to teach the phase order, or hide them and read as a missing feature — are both avoided by keeping the heading and dropping the controls. --- CHANGELOG.md | 3 + .../2026-09-20-multiple-bids-per-name.md | 72 ++++++++++++++++--- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac6183b4..fff4a59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ - **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 +- **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/specs/2026-09-20-multiple-bids-per-name.md b/docs/specs/2026-09-20-multiple-bids-per-name.md index 3f912df4..45e0f83e 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,28 @@ 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); +while a broadcast waits for a block every section is absent, since offering +alternatives then only invites a competing transaction; and the advanced +toggle appears only when at least one section is live. 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 field `nameIsRegistered`. +*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 :: blocks every name action with the reason when the node can't write`. + ## 4. Explicitly not enforced - **A stranded lockup is not recoverable.** R10 reports it; nothing reclaims @@ -240,8 +290,13 @@ 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. +- **A section that is still ahead is not a hidden feature.** R19 renders it as + one muted line naming what unlocks it. It is deliberately not expandable: + 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 +306,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 +339,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 +349,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`. From ed0d32e10819bacc064c34a269ec88f35f054918 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:31:54 +0200 Subject: [PATCH 07/17] fix(names): keep a test-only covenant import out of the lib build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `COV_TRANSFER` is compared against only in the capability tests — the code itself checks against COV_REGISTER — so importing it at module level failed `clippy -D warnings` on the non-test build. --- src-tauri/src/commands/names.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index f3096d15..8366353b 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -23,7 +23,7 @@ use crate::noncustodial::network::Network; use crate::noncustodial::node_rpc::NodeRpc; use crate::noncustodial::rpc::NodeRpcClient; use crate::noncustodial::send::{self, SpendableCoin}; -use crate::noncustodial::sync::{self, COV_REGISTER, COV_REVEAL, COV_TRANSFER}; +use crate::noncustodial::sync::{self, COV_REGISTER, COV_REVEAL}; use crate::noncustodial::tx::sighash; use crate::noncustodial::types::TxDraftSummary; use crate::noncustodial::{address, bids, covenants, names, resource}; @@ -3376,6 +3376,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; // ------------------------------------------------------------------ From 6e7134086301cbf04d4c19c1ba3696afc6c6a5a6 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:43:01 +0200 Subject: [PATCH 08/17] fix(names): close what the review of this change found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings, the first two of them real bugs shipped by the commits above. `resolveSections` read `taskState === "transferPendingFinalize"` to decide whether a transfer was in flight. That comes from the phase string, while the `can_update` gate it is meant to mirror keys on `transfer_has_items` — a second source of truth, and exactly the re-derivation this whole change was about removing. Where the two disagree the records section stood open over a button the node refuses. The backend now reports `transfer_pending` and the UI reads it. `nameIsRegistered` was optional on the TypeScript side "so existing fixtures stay valid", which the bridge rule in CODING_STANDARDS forbids: the Rust field is a plain `bool`, and adding a field means updating every fixture that builds the object. Leaving it optional hid the real casualty — `webqa-mock.ts` is not a fixture but the web-QA harness, and it never set the flag, so in that mode every advanced section vanished and Sign message never rendered. Both fields are now required and every builder states them. Auto-expand could leave the advanced container open while every section had since gone absent — a broadcast going out does that — rendering an empty bordered box. `anyLive` now guards the container, not just the toggle. The rest are hygiene: the two comments that still justified themselves by an advanced toggle they no longer feed, and an UpcomingSection test id built out of display copy, so rewording a heading renamed the id. The spec overclaimed in one place, and is corrected rather than the code: an upcoming section rides alongside a live one and is not shown on its own, because a menu whose whole content is "come back later" is the empty menu this change exists to remove. --- .../2026-09-20-multiple-bids-per-name.md | 24 +++++--- src-tauri/src/commands/names.rs | 58 +++++++++++++++++++ src/components/NameActionsModal.tsx | 31 ++++++---- .../__tests__/auction-positions.test.tsx | 2 + src/components/__tests__/auction-ux.test.tsx | 8 +++ .../__tests__/auction-view-task.test.tsx | 8 +++ .../__tests__/bid-form-validation.test.tsx | 2 + .../__tests__/name-acquisition.test.tsx | 12 ++++ .../__tests__/name-actions-bid-gate.test.tsx | 2 + .../name-actions-dns-prefill.test.tsx | 1 + .../name-actions-draft-cleanup.test.tsx | 2 + .../name-actions-error-handling.test.tsx | 2 + .../__tests__/name-actions-gating.test.tsx | 4 +- .../__tests__/name-modal-sections.test.tsx | 26 ++++++++- .../name-actions/UpcomingSection.tsx | 13 ++++- .../__tests__/name-sign-message.test.tsx | 1 + src/lib/auction.test.ts | 2 + src/lib/nameSections.test.ts | 24 ++++++++ src/lib/nameSections.ts | 6 +- src/lib/webqa-mock.ts | 3 + src/types/index.ts | 12 +++- 21 files changed, 216 insertions(+), 27 deletions(-) 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 45e0f83e..375a6eb2 100644 --- a/docs/specs/2026-09-20-multiple-bids-per-name.md +++ b/docs/specs/2026-09-20-multiple-bids-per-name.md @@ -258,21 +258,24 @@ verbs.** Each of the three — manual auction actions, DNS records, ownership **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); -while a broadcast waits for a block every section is absent, since offering -alternatives then only invites a competing transaction; and the advanced -toggle appears only when at least one section is live. Every gate that read +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 field `nameIsRegistered`. +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 :: blocks every name action with the reason when the node can't write`. +`name-actions-gating.test.tsx :: states the reason once and offers no menu when the node can't write`. ## 4. Explicitly not enforced @@ -290,9 +293,12 @@ capability field `nameIsRegistered`. - **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. -- **A section that is still ahead is not a hidden feature.** R19 renders it as - one muted line naming what unlocks it. It is deliberately not expandable: - expanding would reveal nothing. +- **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 diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 8366353b..924dbc88 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -356,6 +356,12 @@ pub struct NameActionCapabilities { /// 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, @@ -1211,6 +1217,7 @@ pub(crate) fn build_name_action_capabilities( 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, @@ -1259,6 +1266,7 @@ pub(crate) fn conservative_capabilities(name: &str, reason: &str) -> NameActionC // 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, @@ -4876,6 +4884,56 @@ mod tests { ); } + /// 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] fn build_owner_actions_disallowed_when_not_owned() { let ctx = ctx_default(); diff --git a/src/components/NameActionsModal.tsx b/src/components/NameActionsModal.tsx index e122a24c..d17826ef 100644 --- a/src/components/NameActionsModal.tsx +++ b/src/components/NameActionsModal.tsx @@ -331,8 +331,10 @@ export function NameActionsModal({ 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" || @@ -346,9 +348,10 @@ export function NameActionsModal({ // 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. @@ -863,10 +866,18 @@ export function NameActionsModal({
)} - {showAllActions && ( -
+ {/* `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" && (
@@ -911,7 +922,7 @@ export function NameActionsModal({ )} {sections.records.kind === "upcoming" && ( - + )} {recordsLive && (
@@ -1020,7 +1031,7 @@ export function NameActionsModal({ )} {sections.ownership.kind === "upcoming" && ( - + )} {sections.ownership.kind === "live" && ( <> diff --git a/src/components/__tests__/auction-positions.test.tsx b/src/components/__tests__/auction-positions.test.tsx index dfcfbe7c..74c178de 100644 --- a/src/components/__tests__/auction-positions.test.tsx +++ b/src/components/__tests__/auction-positions.test.tsx @@ -55,6 +55,8 @@ function baseCaps(name: string, overrides: Record) { phase: "AVAILABLE", taskState: "unavailableOther", ownsName: false, + nameIsRegistered: false, + transferPending: false, 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..bceb1a61 100644 --- a/src/components/__tests__/auction-ux.test.tsx +++ b/src/components/__tests__/auction-ux.test.tsx @@ -66,6 +66,8 @@ describe("WalletView — auction UX", () => { phase: "UNKNOWN", taskState: "unavailableOther", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -359,6 +361,8 @@ describe("NameActionsModal — phase header + DNS editor", () => { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -470,6 +474,8 @@ describe("NameActionsModal — local bid shown before reveal", () => { phase: "BIDDING", taskState: "waitingForBidding", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: opts.hasBidCommitment, hasRevealCoin: false, hasOwnerCoin: false, @@ -575,6 +581,8 @@ describe("NameActionsModal — recover bid commitment (Task 2 / C2)", () => { phase: "REVEAL", taskState: "unavailableOther", ownsName: false, + nameIsRegistered: false, + transferPending: false, 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..c33f190a 100644 --- a/src/components/__tests__/auction-view-task.test.tsx +++ b/src/components/__tests__/auction-view-task.test.tsx @@ -55,6 +55,8 @@ function baseCaps(name: string, overrides: Record) { phase: "AVAILABLE", taskState: "availableToOpen", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -101,6 +103,8 @@ const CAPS_BY_NAME: Record> = { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, hasOwnerCoin: true, canOpen: { allowed: false, reason: "Phase is CLOSED" }, canBid: { allowed: false, reason: "Phase is CLOSED" }, @@ -278,6 +282,8 @@ describe("AuctionsView — task-driven row rendering", () => { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, hasOwnerCoin: true, canOpen: { allowed: false, reason: "Phase is CLOSED" }, canBid: { allowed: false, reason: "Phase is CLOSED" }, @@ -299,6 +305,8 @@ describe("AuctionsView — task-driven row rendering", () => { phase: "CLOSED", taskState: "ownedNoAction", ownsName: true, + nameIsRegistered: true, + transferPending: false, 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..ff9c6ace 100644 --- a/src/components/__tests__/bid-form-validation.test.tsx +++ b/src/components/__tests__/bid-form-validation.test.tsx @@ -38,6 +38,8 @@ function route() { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, 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 8ef0fff3..9200b99b 100644 --- a/src/components/__tests__/name-acquisition.test.tsx +++ b/src/components/__tests__/name-acquisition.test.tsx @@ -414,6 +414,8 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "AVAILABLE", taskState: "waitingForBidding", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, @@ -483,6 +485,8 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, @@ -568,6 +572,8 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "BIDDING", taskState: "waitingForBidding", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: true, hasRevealCoin: false, hasOwnerCoin: false, @@ -650,6 +656,8 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -700,6 +708,7 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, @@ -758,6 +767,8 @@ describe("NameActionsModal — guided acquisition flow", () => { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + nameIsRegistered: true, + transferPending: false, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -823,6 +834,7 @@ describe("NameActionsModal — guided acquisition flow", () => { // exist (that is the stage), and each button carries its own // "not synced" reason. nameIsRegistered: true, + transferPending: false, 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 f94ac5c0..f06bc4ef 100644 --- a/src/components/__tests__/name-actions-bid-gate.test.tsx +++ b/src/components/__tests__/name-actions-bid-gate.test.tsx @@ -44,6 +44,8 @@ function route(phase: "OPENING" | "BIDDING", canBidAllowed: boolean) { phase, taskState: "none", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: canBidAllowed ? false : phase === "BIDDING", hasBidCoin: false, hasRevealCoin: false, diff --git a/src/components/__tests__/name-actions-dns-prefill.test.tsx b/src/components/__tests__/name-actions-dns-prefill.test.tsx index 4e97c6fa..fa291c59 100644 --- a/src/components/__tests__/name-actions-dns-prefill.test.tsx +++ b/src/components/__tests__/name-actions-dns-prefill.test.tsx @@ -35,6 +35,7 @@ const ownedCaps = { phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, diff --git a/src/components/__tests__/name-actions-draft-cleanup.test.tsx b/src/components/__tests__/name-actions-draft-cleanup.test.tsx index 4f7600c9..d179cd40 100644 --- a/src/components/__tests__/name-actions-draft-cleanup.test.tsx +++ b/src/components/__tests__/name-actions-draft-cleanup.test.tsx @@ -43,6 +43,8 @@ function route(overrides: Record Promise> = {}) { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, 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..35d47381 100644 --- a/src/components/__tests__/name-actions-error-handling.test.tsx +++ b/src/components/__tests__/name-actions-error-handling.test.tsx @@ -42,6 +42,8 @@ function route(overrides: Record Promise> = {}) { phase: "BIDDING", taskState: "readyToBid", ownsName: false, + nameIsRegistered: false, + transferPending: false, 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 e22064d1..d61e3b41 100644 --- a/src/components/__tests__/name-actions-gating.test.tsx +++ b/src/components/__tests__/name-actions-gating.test.tsx @@ -44,6 +44,8 @@ function route( phase: "CLOSED", taskState: "wonNeedsRegister", ownsName: true, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -114,7 +116,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, diff --git a/src/components/__tests__/name-modal-sections.test.tsx b/src/components/__tests__/name-modal-sections.test.tsx index df19563d..d54ddaaf 100644 --- a/src/components/__tests__/name-modal-sections.test.tsx +++ b/src/components/__tests__/name-modal-sections.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom"; -import { render, screen, waitFor } from "@testing-library/react"; +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"; @@ -49,6 +49,7 @@ function capsFor(over: Record) { phase: "CLOSED", taskState: "unavailableOther", ownsName: false, + transferPending: false, nameIsRegistered: false, hasBidCommitment: false, hasBidCoin: false, @@ -139,6 +140,7 @@ describe("NameActionsModal — sections while the auction is still running", () taskState: "revealDoneWaitingForClose", // hsd names the highest revealer as owner long before anyone has won. ownsName: true, + transferPending: false, nameIsRegistered: false, hasOwnerCoin: true, hasRevealCoin: true, @@ -159,6 +161,23 @@ describe("NameActionsModal — sections while the auction is still running", () 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( {}} />, { @@ -217,6 +236,7 @@ describe("NameActionsModal — sections while a broadcast waits for a block", () name: "pendingname", taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, nameIsRegistered: true, hasOwnerCoin: true, canUpdate: ok, @@ -233,6 +253,9 @@ describe("NameActionsModal — sections while a broadcast waits for a block", () 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(); }); }); @@ -255,6 +278,7 @@ describe("NameActionsModal — sections on a name the wallet really owns", () => name: "ownedname", taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, nameIsRegistered: true, hasOwnerCoin: true, canUpdate: ok, diff --git a/src/components/name-actions/UpcomingSection.tsx b/src/components/name-actions/UpcomingSection.tsx index a9a30086..c1f0ef02 100644 --- a/src/components/name-actions/UpcomingSection.tsx +++ b/src/components/name-actions/UpcomingSection.tsx @@ -6,9 +6,18 @@ * sections on screen — while the buttons go, because a wall of disabled * controls is what made a user ask whether any of them were real. */ -export function UpcomingSection({ title, when }: { title: string; when: string }) { +export function UpcomingSection({ + id, + title, + when, +}: { + /** Stable key for tests — the heading is display copy and may be reworded. */ + id: string; + title: string; + when: string; +}) { return ( -
+
{title} — {when}
); diff --git a/src/components/name-actions/__tests__/name-sign-message.test.tsx b/src/components/name-actions/__tests__/name-sign-message.test.tsx index 9c361856..abbc2cdc 100644 --- a/src/components/name-actions/__tests__/name-sign-message.test.tsx +++ b/src/components/name-actions/__tests__/name-sign-message.test.tsx @@ -37,6 +37,7 @@ function capsFor(name: string, ownsName: boolean): NameActionCapabilities { ownsName, // Signing proves ownership, which does not exist before REGISTER. nameIsRegistered: ownsName, + transferPending: false, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, diff --git a/src/lib/auction.test.ts b/src/lib/auction.test.ts index 67605567..524ec6ed 100644 --- a/src/lib/auction.test.ts +++ b/src/lib/auction.test.ts @@ -246,6 +246,8 @@ describe("taskSummaryFromCapabilities — genuine-bidding label unification", () phase: "BIDDING", taskState: "waitingForBidding", ownsName: false, + nameIsRegistered: false, + transferPending: false, hasBidCommitment: true, hasBidCoin: true, hasRevealCoin: false, diff --git a/src/lib/nameSections.test.ts b/src/lib/nameSections.test.ts index 405af3eb..70d839c1 100644 --- a/src/lib/nameSections.test.ts +++ b/src/lib/nameSections.test.ts @@ -13,6 +13,7 @@ function caps(over: Partial = {}): NameActionCapabilitie phase: "CLOSED", taskState: "unavailableOther", ownsName: false, + transferPending: false, nameIsRegistered: false, hasBidCommitment: false, hasBidCoin: false, @@ -53,6 +54,7 @@ describe("resolveSections — leading your own auction is not owning the name", phase: "REVEAL", taskState: "revealDoneWaitingForClose", ownsName: true, + transferPending: false, nameIsRegistered: false, hasRevealCoin: true, }); @@ -86,6 +88,7 @@ describe("resolveSections — while a transaction is waiting for a block", () => phase: "CLOSED", taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, nameIsRegistered: true, canUpdate: yes, canRenew: yes, @@ -108,6 +111,7 @@ describe("resolveSections — records", () => { caps({ taskState: "wonNeedsRegister", ownsName: true, + transferPending: false, nameIsRegistered: false, canRegister: yes, }), @@ -126,6 +130,7 @@ describe("resolveSections — records", () => { phase: "TRANSFER", taskState: "transferPendingFinalize", ownsName: true, + transferPending: true, nameIsRegistered: true, canFinalize: yes, canCancelTransfer: yes, @@ -136,6 +141,24 @@ describe("resolveSections — records", () => { expect(s.ownership.kind).toBe("live"); }); + // The gate this mirrors is `can_update`, which keys on the transfer's items. + // Deriving it from the task state instead is a second source of truth: the + // two can disagree, and then the section says one thing and the button + // inside it does another. + it("follows the backend's transfer flag, not the phase-derived task state", () => { + const s = resolveSections( + caps({ + phase: "CLOSED", + taskState: "ownedNoUrgentAction", + ownsName: true, + nameIsRegistered: true, + transferPending: true, + canUpdate: no("a transfer is pending — updating records would cancel it"), + }), + ); + expect(s.records.kind).toBe("upcoming"); + }); + it("is absent on a name this wallet has nothing to do with", () => { const s = resolveSections(caps({ phase: "BIDDING", ownsName: false })); expect(s.records.kind).toBe("absent"); @@ -152,6 +175,7 @@ describe("resolveSections — auction", () => { caps({ taskState: "ownedNoUrgentAction", ownsName: true, + transferPending: false, nameIsRegistered: true, canUpdate: yes, }), diff --git a/src/lib/nameSections.ts b/src/lib/nameSections.ts index 56d75072..21a15f79 100644 --- a/src/lib/nameSections.ts +++ b/src/lib/nameSections.ts @@ -73,7 +73,11 @@ export function resolveSections(caps: NameActionCapabilities | null | undefined) // registered. A pending transfer takes it away again: hsd accepts // TRANSFER -> UPDATE and that transition is the cancel, so Update here would // end the transfer while saying nothing about transfers. - const transferPending = caps?.taskState === "transferPendingFinalize"; + // The backend's own flag, the same `transfer_has_items` `can_update` keys + // on. The task state is a different question — it comes from the phase + // string — and where the two disagree this section would stand open over a + // button the node refuses. + const transferPending = caps?.transferPending === true; const records: SectionState = !ours ? { kind: "absent" } : transferPending diff --git a/src/lib/webqa-mock.ts b/src/lib/webqa-mock.ts index ff6b2be7..9c5c5927 100644 --- a/src/lib/webqa-mock.ts +++ b/src/lib/webqa-mock.ts @@ -275,6 +275,9 @@ function buildCapabilities(name: string): Record { phase, taskState, ownsName: won, + // The mock's won names are registered: it allows update/transfer on them. + nameIsRegistered: won, + transferPending: false, hasBidCommitment: a.hasBid, hasBidCoin, hasRevealCoin, diff --git a/src/types/index.ts b/src/types/index.ts index 989fc05e..2302ab3d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -610,10 +610,16 @@ export interface NameActionCapabilities { * later. `ownsName` is NOT this: during REVEAL hsd already reports the * highest revealer as the owner, so a wallet merely leading its own auction * reads as an owner while holding nothing but a REVEAL coin. The backend - * states it so the UI stops re-deriving a wrong answer. Optional so existing - * fixtures stay valid; absent is read as "not registered". + * states it so the UI stops re-deriving a wrong answer. */ - nameIsRegistered?: boolean; + nameIsRegistered: boolean; + /** + * Whether a TRANSFER is in flight — the same `transfer_has_items` the + * capability gates use. The phase string is a different question and the two + * can disagree, which would leave the records section open on a name whose + * Update is refused (editing records mid-transfer cancels the transfer). + */ + transferPending: boolean; hasBidCommitment: boolean; /** Unspent COV_BID coin for this name — what a REVEAL actually spends. * Gates `canReveal`. Backend fix (Task 6 / I2 Part 3): `hasRevealCoin` From 04f28659b4ad474639bee0083babf82d332441b9 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 18:53:18 +0200 Subject: [PATCH 09/17] fix(names): a stranded lockup is not something left to reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on a live regtest wallet. `vmp3rt3` had all four of this auction's bids revealed in one transaction, and Reveal was still offered — every press failing with "no unspent bid coin for 'vmp3rt3' (sync first?)". The refusal was right and the button was wrong. `can_reveal` gates on `has_bid_coin`, which searched unspent BID coins by name hash across the whole profile, while `build_reveal_draft` resolves them from the commitments of the current auction. That wallet holds a lockup stranded in an auction that lapsed at height 111, and a stranded BID coin can never be revealed — a REVEAL is only valid while `start == ns.height`. So it answered "yes, there is a bid coin" for an auction it has nothing to do with, permanently, on a name with nothing left to do. `has_bid_coin` now comes from the same set the draft builds its transaction from, so the button and the builder cannot disagree. The reason the lookup left the commitment's address in the first place still holds — "the newest commitment" was never well defined — and is kept by searching every commitment of this auction rather than one of them. The stranded lockup is still reported; it is just no longer mistaken for work outstanding. --- src-tauri/src/commands/names.rs | 36 +++++++++----- .../src/tests/names_action_context_tests.rs | 48 +++++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 924dbc88..1acb9292 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -550,20 +550,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, diff --git a/src-tauri/src/tests/names_action_context_tests.rs b/src-tauri/src/tests/names_action_context_tests.rs index 92ceab87..41eaae73 100644 --- a/src-tauri/src/tests/names_action_context_tests.rs +++ b/src-tauri/src/tests/names_action_context_tests.rs @@ -465,6 +465,54 @@ 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" + ); +} + /// 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() { From bf140ef4ee8e7c5d221b6e8c48267f3615839168 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Sun, 20 Sep 2026 19:06:40 +0200 Subject: [PATCH 10/17] fix(names): say what the name needs, and stop implying DNS is required MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a live wallet made confusing at the same moment. The Owned Names table said "Closed" for a name whose modal, opened from that very row, was headed "Won — Register Now". Both were real data — the auction phase and the task state — but one name described two ways is a reader's problem, not a reader's mistake. The Auctions list and the modal already agree through `taskSummaryFromCapabilities`; the table now reads the same summary, and falls back to the raw phase only before the capabilities land or on a watch-only profile that never fetches them. The phase was nearly a constant down that column anyway: every name you own has a closed auction. The three places that indexed the capability batch now share one index, so they cannot drift. The Register panel put a DNS record editor in front of the user and said nothing about it, which reads as "records required to register". They are not: hsd caps the resource size and accepts an empty one (`rules.verifyCovenants`, REGISTER items[2]), and the wallet already sends an empty resource when the editor is untouched. The panel now says registering claims the name and records can follow with Update, and keeps the editor behind "Add DNS records now (optional)" so the ordinary path is one button. --- src/components/WalletView.tsx | 43 ++++++++---- src/components/__tests__/auction-ux.test.tsx | 3 + .../__tests__/name-modal-sections.test.tsx | 51 ++++++++++++++ src/components/__tests__/wallet-view.test.tsx | 67 +++++++++++++++++++ src/components/name-actions/GuidedAction.tsx | 38 +++++++++-- 5 files changed, 183 insertions(+), 19 deletions(-) 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-ux.test.tsx b/src/components/__tests__/auction-ux.test.tsx index bceb1a61..5978eeea 100644 --- a/src/components/__tests__/auction-ux.test.tsx +++ b/src/components/__tests__/auction-ux.test.tsx @@ -441,6 +441,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"), { diff --git a/src/components/__tests__/name-modal-sections.test.tsx b/src/components/__tests__/name-modal-sections.test.tsx index d54ddaaf..e2584dad 100644 --- a/src/components/__tests__/name-modal-sections.test.tsx +++ b/src/components/__tests__/name-modal-sections.test.tsx @@ -296,3 +296,54 @@ describe("NameActionsModal — sections on a name the wallet really owns", () => 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(); + }); +}); diff --git a/src/components/__tests__/wallet-view.test.tsx b/src/components/__tests__/wallet-view.test.tsx index 7d4c93d7..29615a1c 100644 --- a/src/components/__tests__/wallet-view.test.tsx +++ b/src/components/__tests__/wallet-view.test.tsx @@ -1737,3 +1737,70 @@ describe("WalletView — keyboard S key (wallet:send)", () => { }); }); }); + +describe("WalletView — Owned Names State column", () => { + // Reported from a live wallet: the table said "Closed" while the modal it + // opens said "Won — Register Now". Both were reading real data — the raw + // auction phase and the task state — but a user sees one name described two + // ways. The auction phase is also nearly constant down this column: every + // name you own has a closed auction, so it spends a column to say nothing. + it("says what the name needs, not which auction phase it is in", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "get_names_action_capabilities") { + return Promise.resolve([ + { + name: "wonname", + phase: "CLOSED", + taskState: "wonNeedsRegister", + ownsName: true, + nameIsRegistered: false, + transferPending: false, + hasBidCommitment: false, + hasBidCoin: false, + hasRevealCoin: true, + hasOwnerCoin: true, + revealTxid: null, + bidValueDoos: null, + lockupValueDoos: null, + myBidCount: 0, + canOpen: { allowed: false, reason: null }, + canBid: { allowed: false, reason: null }, + canReveal: { allowed: false, reason: null }, + canRedeem: { allowed: false, reason: null }, + canRegister: { allowed: true, reason: null }, + canUpdate: { allowed: false, reason: null }, + canTransfer: { allowed: false, reason: null }, + canFinalize: { allowed: false, reason: null }, + canCancelTransfer: { allowed: false, reason: null }, + canRenew: { allowed: false, reason: null }, + canRevoke: { allowed: false, reason: null }, + nextActionKey: "REGISTER", + nextActionLabel: "Register Name", + nextActionReason: null, + countdownLabel: null, + countdownBlocks: null, + countdownHours: null, + }, + ]); + } + return routeInvoke({ + names: [ + { + name: "wonname", + state: "CLOSED", + height: 100, + renewal: 200, + owner: { hash: "tx1", index: 0 }, + registered: false, + stats: null, + }, + ], + })(cmd); + }); + + render(, { wrapper: wrapper() }); + + expect(await screen.findByText("Won — Register Now")).toBeInTheDocument(); + expect(screen.queryByText("Closed")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/name-actions/GuidedAction.tsx b/src/components/name-actions/GuidedAction.tsx index 5a1a0acc..f54f71f5 100644 --- a/src/components/name-actions/GuidedAction.tsx +++ b/src/components/name-actions/GuidedAction.tsx @@ -112,6 +112,8 @@ export function GuidedAction({ isMainnet, }: GuidedActionProps) { const [infoTx, setInfoTx] = useState(null); + // Records are not part of registering — see the Register panel below. + const [showRegisterDns, setShowRegisterDns] = useState(false); if (!guide) return null; // A transaction this wallet sent is still in the mempool. The chain has not @@ -411,13 +413,35 @@ export function GuidedAction({ "You won the auction! Register the name to finalize ownership."}
- + {/* 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 ? ( + + ) : ( + + )}
-
- + {/* 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)} +
+
+ )} )} @@ -995,22 +1014,28 @@ export function NameActionsModal({ )}
- - - + + + )} ) { 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 5978eeea..f62c8cb1 100644 --- a/src/components/__tests__/auction-ux.test.tsx +++ b/src/components/__tests__/auction-ux.test.tsx @@ -68,6 +68,8 @@ describe("WalletView — auction UX", () => { ownsName: false, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -363,6 +365,8 @@ describe("NameActionsModal — phase header + DNS editor", () => { ownsName: true, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -479,6 +483,8 @@ describe("NameActionsModal — local bid shown before reveal", () => { ownsName: false, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: opts.hasBidCommitment, hasRevealCoin: false, hasOwnerCoin: false, @@ -586,6 +592,8 @@ describe("NameActionsModal — recover bid commitment (Task 2 / C2)", () => { 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 c33f190a..f0dc5835 100644 --- a/src/components/__tests__/auction-view-task.test.tsx +++ b/src/components/__tests__/auction-view-task.test.tsx @@ -57,6 +57,8 @@ function baseCaps(name: string, overrides: Record) { ownsName: false, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -105,6 +107,8 @@ const CAPS_BY_NAME: Record> = { 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" }, @@ -284,6 +288,8 @@ describe("AuctionsView — task-driven row rendering", () => { 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" }, @@ -307,6 +313,8 @@ describe("AuctionsView — task-driven row rendering", () => { 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 ff9c6ace..9263ffaa 100644 --- a/src/components/__tests__/bid-form-validation.test.tsx +++ b/src/components/__tests__/bid-form-validation.test.tsx @@ -40,6 +40,8 @@ function route() { 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 9200b99b..b1a16d08 100644 --- a/src/components/__tests__/name-acquisition.test.tsx +++ b/src/components/__tests__/name-acquisition.test.tsx @@ -416,6 +416,8 @@ describe("NameActionsModal — guided acquisition flow", () => { ownsName: false, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, @@ -487,6 +489,8 @@ describe("NameActionsModal — guided acquisition flow", () => { ownsName: false, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasBidCoin: false, hasRevealCoin: false, @@ -574,6 +578,8 @@ describe("NameActionsModal — guided acquisition flow", () => { ownsName: false, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: true, hasRevealCoin: false, hasOwnerCoin: false, @@ -658,6 +664,8 @@ describe("NameActionsModal — guided acquisition flow", () => { ownsName: true, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, @@ -709,6 +717,8 @@ describe("NameActionsModal — guided acquisition flow", () => { taskState: "ownedNoUrgentAction", ownsName: true, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, @@ -769,6 +779,8 @@ describe("NameActionsModal — guided acquisition flow", () => { ownsName: true, nameIsRegistered: true, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: false, @@ -835,6 +847,8 @@ describe("NameActionsModal — guided acquisition flow", () => { // "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 f06bc4ef..a56d1948 100644 --- a/src/components/__tests__/name-actions-bid-gate.test.tsx +++ b/src/components/__tests__/name-actions-bid-gate.test.tsx @@ -46,6 +46,8 @@ function route(phase: "OPENING" | "BIDDING", canBidAllowed: boolean) { ownsName: false, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: canBidAllowed ? false : phase === "BIDDING", hasBidCoin: false, hasRevealCoin: false, diff --git a/src/components/__tests__/name-actions-dns-prefill.test.tsx b/src/components/__tests__/name-actions-dns-prefill.test.tsx index fa291c59..ab979f72 100644 --- a/src/components/__tests__/name-actions-dns-prefill.test.tsx +++ b/src/components/__tests__/name-actions-dns-prefill.test.tsx @@ -36,6 +36,8 @@ const ownedCaps = { taskState: "ownedNoUrgentAction", ownsName: true, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, nameIsRegistered: true, hasBidCommitment: false, hasRevealCoin: false, diff --git a/src/components/__tests__/name-actions-draft-cleanup.test.tsx b/src/components/__tests__/name-actions-draft-cleanup.test.tsx index d179cd40..9a98d526 100644 --- a/src/components/__tests__/name-actions-draft-cleanup.test.tsx +++ b/src/components/__tests__/name-actions-draft-cleanup.test.tsx @@ -45,6 +45,8 @@ function route(overrides: Record Promise> = {}) { 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 35d47381..5420a3d8 100644 --- a/src/components/__tests__/name-actions-error-handling.test.tsx +++ b/src/components/__tests__/name-actions-error-handling.test.tsx @@ -44,6 +44,8 @@ function route(overrides: Record Promise> = {}) { 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 d61e3b41..fcf4a1fb 100644 --- a/src/components/__tests__/name-actions-gating.test.tsx +++ b/src/components/__tests__/name-actions-gating.test.tsx @@ -46,6 +46,8 @@ function route( ownsName: true, nameIsRegistered: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, hasBidCommitment: false, hasRevealCoin: false, hasOwnerCoin: true, diff --git a/src/components/__tests__/name-modal-sections.test.tsx b/src/components/__tests__/name-modal-sections.test.tsx index e2584dad..5e8962c1 100644 --- a/src/components/__tests__/name-modal-sections.test.tsx +++ b/src/components/__tests__/name-modal-sections.test.tsx @@ -50,6 +50,8 @@ function capsFor(over: Record) { taskState: "unavailableOther", ownsName: false, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, nameIsRegistered: false, hasBidCommitment: false, hasBidCoin: false, @@ -141,6 +143,8 @@ describe("NameActionsModal — sections while the auction is still running", () // 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, @@ -237,6 +241,8 @@ describe("NameActionsModal — sections while a broadcast waits for a block", () taskState: "ownedNoUrgentAction", ownsName: true, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, nameIsRegistered: true, hasOwnerCoin: true, canUpdate: ok, @@ -279,6 +285,8 @@ describe("NameActionsModal — sections on a name the wallet really owns", () => taskState: "ownedNoUrgentAction", ownsName: true, transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, nameIsRegistered: true, hasOwnerCoin: true, canUpdate: ok, @@ -347,3 +355,68 @@ describe("NameActionsModal — the Register step", () => { 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 + // Block wrapper on purpose: a bare inline +
)}