From d1b11275a436684bbbd79dd13574614cec8f1af8 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 14:26:02 +0200 Subject: [PATCH 1/9] fix(names): count every output a name action carries, and defer to what is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two figures that were right about one thing and shown for another. The confirm dialog reported `outputs[0]` as the amount. A name action can carry several: revealing a name you bid on more than once emits one REVEAL per bid, and redeeming reclaims one per losing reveal. A live wallet was offered a redeem of three reveals worth 28 HNS with 12 on the dialog — the single figure a user checks before signing, wrong on every multi-bid action. It now sums every output except change, which the plan already identifies by index. The Owned Names State column printed the task while a transaction for that name was in flight, so a row read "Owned" and the modal it opened read "Redeem · waiting for a block". The modal and the auctions list already defer to what is in flight; this column was the last one that did not. --- src-tauri/src/commands/names.rs | 15 +++- .../src/tests/build_redeem_draft_tests.rs | 62 +++++++++++++++ src/components/WalletView.tsx | 19 ++++- src/components/__tests__/wallet-view.test.tsx | 75 +++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 338bf584..77916249 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -231,7 +231,20 @@ fn persist_with_conn( let summary = ActionSummary { action, name, - send_total_doos: res.plan.outputs[0].value as i64, + // Every output the action carries, change excluded — not the first + // one. A name action can have several: revealing a name you bid on + // more than once emits one REVEAL per bid, and redeeming reclaims one + // per losing reveal. Reporting `outputs[0]` made the confirm dialog + // offer to reclaim 28 HNS and print 12, which is the one figure a user + // checks before signing. + send_total_doos: res + .plan + .outputs + .iter() + .enumerate() + .filter(|(i, _)| Some(*i) != res.plan.change_output_index) + .map(|(_, o)| o.value as i64) + .sum(), fee_doos: res.fee as i64, change_doos: res.change as i64, input_total_doos: res.input_total as i64, diff --git a/src-tauri/src/tests/build_redeem_draft_tests.rs b/src-tauri/src/tests/build_redeem_draft_tests.rs index 8c3985f9..bb607921 100644 --- a/src-tauri/src/tests/build_redeem_draft_tests.rs +++ b/src-tauri/src/tests/build_redeem_draft_tests.rs @@ -221,6 +221,68 @@ fn build_redeem_draft_reclaims_reveal_value_to_its_address() { ); } +/// Reported from a live wallet: the confirm dialog offered to reclaim three +/// losing bids worth 28 HNS and showed 12 — the value of the first output +/// alone. Outbidding yourself is the ordinary case this whole path exists for, +/// so a multi-output redeem is not an edge, and the one figure the user checks +/// before signing was wrong on every one of them. +#[test] +fn build_redeem_draft_totals_every_output_it_reclaims() { + let conn = test_db(); + seed_profile(&conn); + let network = Network::Main; + let xpub = test_xpub(); + let change = derivation::derive_one(network, &xpub, BRANCH_CHANGE, 0).unwrap(); + let recv0 = derivation::derive_one(network, &xpub, derivation::BRANCH_RECEIVE, 0).unwrap(); + + let funding_txid = "aa".repeat(32); + seed_liquid_coin(&conn, &funding_txid, 10_000_000, &recv0.address); + + // Three losing reveals, as a wallet that bid three times against itself + // ends up holding. + let values = [12_000_000u64, 5_000_000, 11_000_000]; + let mut coins = Vec::new(); + for (i, v) in values.iter().enumerate() { + let txid = format!("{:02x}", 0xb0 + i).repeat(32); + seed_reveal_coin(&conn, &txid, *v as i64, &recv0.address); + coins.push(reveal_coin(&txid, &recv0.address, *v)); + } + + let ctx = Ctx { + profile_id: PROFILE.into(), + network, + account: 0, + account_xpub: xpub, + change_address: change.address, + funding: vec![SpendableCoin { + txid: funding_txid, + vout: 0, + value: 10_000_000, + branch: derivation::BRANCH_RECEIVE, + child_index: 0, + }], + settings: HashMap::new(), + node: crate::noncustodial::rpc::NodeRpcClient::new( + "http://127.0.0.1:1", + "", + crate::noncustodial::rpc::ChainSource::LocalNode, + ), + }; + + let summary = + build_redeem_draft_inner(&conn, &ctx, NAME, Some(10), &closed_name_state(), &coins) + .unwrap(); + let send_total = summary + .summary + .get("sendTotalDoos") + .and_then(|v| v.as_i64()); + assert_eq!( + send_total, + Some(28_000_000), + "every reclaimed reveal counts, not just the first" + ); +} + #[test] fn build_redeem_draft_uses_explicit_fee_rate() { let (conn, ctx, coin) = setup(2_000_000, 10_000_000); diff --git a/src/components/WalletView.tsx b/src/components/WalletView.tsx index 39a46d43..7b6d2100 100644 --- a/src/components/WalletView.tsx +++ b/src/components/WalletView.tsx @@ -26,7 +26,12 @@ import { import { useStartFullSync, useSyncStatus, useCancelFullSync } from "../queries/sync"; import { useNodeLive, useStartHsd } from "../queries/node"; import { useSyncTriggerStore } from "../stores/syncTrigger"; -import { auctionPhase, formatCountdown, taskSummaryFromCapabilities } from "../lib/auction"; +import { + auctionPhase, + formatCountdown, + pendingBroadcastBadge, + taskSummaryFromCapabilities, +} from "../lib/auction"; import { displayName } from "../lib/idn"; import { NameActionsModal } from "./NameActionsModal"; import { BlockInfoModal } from "./BlockInfoModal"; @@ -1269,6 +1274,18 @@ export function WalletView() { watch-only profile that never fetches them. */} {(() => { const task = taskSummaryFromCapabilities(capsByName.get(n.name)); + // A transaction of ours for this name is in flight: + // the task is a verdict the chain has not reached + // and an instruction already followed. The modal + // and the auctions list both say so; this column + // was the last one still printing the task. + if (task?.pendingBroadcastAction) { + return ( + + {pendingBroadcastBadge(task.pendingBroadcastAction)} + + ); + } if (task) { return {task.label}; } diff --git a/src/components/__tests__/wallet-view.test.tsx b/src/components/__tests__/wallet-view.test.tsx index 32efd0d3..2f125d6e 100644 --- a/src/components/__tests__/wallet-view.test.tsx +++ b/src/components/__tests__/wallet-view.test.tsx @@ -1739,6 +1739,42 @@ describe("WalletView — keyboard S key (wallet:send)", () => { }); describe("WalletView — Owned Names State column", () => { + const capsBase = { + name: "n", + phase: "CLOSED", + taskState: "unavailableOther", + ownsName: false, + nameIsRegistered: false, + transferPending: false, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + hasBidCommitment: false, + hasBidCoin: false, + hasRevealCoin: false, + hasOwnerCoin: false, + 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: false, 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: null, + nextActionLabel: null, + nextActionReason: null, + countdownLabel: null, + countdownBlocks: null, + countdownHours: null, + }; + // 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 @@ -1805,4 +1841,43 @@ describe("WalletView — Owned Names State column", () => { expect(await screen.findByText("Won — Register Now")).toBeInTheDocument(); expect(screen.queryByText("Closed")).not.toBeInTheDocument(); }); + + // Reported live: the row read "Owned" while the modal it opens was headed + // "Redeem · waiting for a block". The modal and the auctions list both + // defer to what is in flight; this column was the last one still printing + // the task as though nothing had been sent. + it("defers to what is in flight, like the modal it opens", async () => { + invokeMock.mockImplementation((cmd: string) => { + if (cmd === "get_names_action_capabilities") { + return Promise.resolve([ + { + ...capsBase, + name: "redeeming", + taskState: "lostNeedsRedeem", + ownsName: true, + nameIsRegistered: true, + canRedeem: { allowed: true, reason: null }, + pendingBroadcastAction: "redeem", + }, + ]); + } + return routeInvoke({ + names: [ + { + name: "redeeming", + state: "CLOSED", + height: 100, + renewal: 200, + owner: { hash: "tx1", index: 0 }, + registered: true, + stats: null, + }, + ], + })(cmd); + }); + + render(, { wrapper: wrapper() }); + + expect(await screen.findByText(/waiting for a block/i)).toBeInTheDocument(); + }); }); From f11bd2de9fecaef8ba1ecc207cfdc57bf3103518 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 14:32:11 +0200 Subject: [PATCH 2/9] fix(sync): a reclaimed lockup is money again, not name-bound value Found by following the money on a live wallet: 28 HNS redeemed from three losing bids landed in `name_control`, so the balance card did not show it as spendable and coin selection would not draw on it. The user had just paid a fee to get it back and it stayed invisible. REDEEM is not a name covenant in the sense the other arms of this match are. hsd's own coin selector draws the line in `Covenant ::isNonspendable()`, which returns false for NONE, OPEN and REDEEM and true for everything else: a redeemed coin spends like any other output. OPEN stays where it is on purpose. hsd would spend it too, but it is a zero-value marker, so counting it as liquid would add an input and no value. No migration: the sync upsert rewrites `spend_class` on conflict, so the next sync reclassifies coins already stored. --- src-tauri/src/noncustodial/sync.rs | 39 ++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/noncustodial/sync.rs b/src-tauri/src/noncustodial/sync.rs index 116072d8..568423d2 100644 --- a/src-tauri/src/noncustodial/sync.rs +++ b/src-tauri/src/noncustodial/sync.rs @@ -63,11 +63,19 @@ impl SpendClass { } /// Classify a covenant type into a spend class. +/// +/// The spendable set follows hsd's own coin selector: `Covenant +/// ::isNonspendable()` returns false for NONE, OPEN and REDEEM, and true for +/// everything else. REDEEM is the one that matters here — it is a lockup the +/// wallet has reclaimed, ordinary money again, and grouping it with the name +/// covenants kept it out of the balance and out of coin selection. OPEN stays +/// with the name covenants deliberately: hsd would spend it, but it is a +/// zero-value marker, so counting it as liquid adds an input and no value. pub fn classify_covenant(covenant_type: u8) -> SpendClass { match covenant_type { - COV_NONE => SpendClass::LiquidHns, + COV_NONE | COV_REDEEM => SpendClass::LiquidHns, COV_BID => SpendClass::NameLockup, - COV_CLAIM | COV_OPEN | COV_REVEAL | COV_REDEEM | COV_REGISTER | COV_UPDATE | COV_RENEW + COV_CLAIM | COV_OPEN | COV_REVEAL | COV_REGISTER | COV_UPDATE | COV_RENEW | COV_TRANSFER | COV_FINALIZE => SpendClass::NameControl, // REVOKE and any unknown future type: don't let coin selection touch it. _ => SpendClass::Unsupported, @@ -506,6 +514,33 @@ mod tests { assert_eq!(classify_covenant(99), SpendClass::Unsupported); } + /// A REDEEM output is the wallet's money back. hsd's own coin selector + /// says so — `Covenant.isNonspendable()` returns false for NONE, OPEN and + /// REDEEM alone — so classifying it with the name covenants left a + /// reclaimed lockup out of the spendable balance and out of coin + /// selection. Found on a live wallet: 28 HNS redeemed from three losing + /// bids landed in `name_control` and could not be spent or seen. + #[test] + fn a_redeemed_lockup_is_ordinary_money_again() { + assert_eq!(classify_covenant(COV_REDEEM), SpendClass::LiquidHns); + } + + #[test] + fn redeemed_value_counts_as_liquid_balance() { + let conn = mem_db(); + upsert_utxo(&conn, "p1", &coin("aa", 0, 1_000_000, None)).unwrap(); + upsert_utxo( + &conn, + "p1", + &coin("rr", 0, 28_000_000, Some(cov(COV_REDEEM))), + ) + .unwrap(); + + let bal = compute_balances(&conn, "p1", Network::Main).unwrap(); + assert_eq!(bal.liquid, 29_000_000, "the reclaimed lockup is spendable"); + assert_eq!(bal.name_control, 0); + } + #[test] fn upsert_and_balances_split_by_class() { let conn = mem_db(); From 35838c89c18f0706af5f0b6fc66b2723c9b2e88d Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 14:37:46 +0200 Subject: [PATCH 3/9] fix(names): Finalize must wait out the transfer lockup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found reading the consensus rules before running a transfer end to end. hsd refuses a FINALIZE until `transfer + transferLockup` blocks have passed — `bad-finalize-maturity` in chain.js — and `can_finalize` asked only whether a transfer existed. So the button went live the moment the transfer was mined and stayed live through the whole lockup, sending the user at a transaction the node throws away: two days on mainnet, ten blocks on regtest. The same fault as the ownership actions before REGISTER, one stage along. The gate now compares the transfer's height against the tip, and the reason counts the blocks left rather than saying no. With either height unknown the action stays offered. Refusing one the node would accept is its own kind of wrong, and the wallet has no business guessing when it cannot see the chain. --- src-tauri/src/commands/names.rs | 128 +++++++++++++++++- src-tauri/src/db/queries.rs | 9 +- .../src/tests/name_capabilities_tests.rs | 8 ++ 3 files changed, 142 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 77916249..eaefe987 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -491,6 +491,14 @@ pub(crate) struct NameActionContext { /// broadcast and the chain has not mined. We still own the name — we just /// cannot spend it until the block lands. pub owner_spend_in_flight: bool, + /// The block the name's TRANSFER was recorded in (`getnameinfo.transfer`), + /// and the wallet's best idea of the tip. Together with the network's + /// `transfer_lockup` they say whether a FINALIZE would be accepted: hsd + /// refuses one until `transfer + lockup` blocks have passed + /// (`bad-finalize-maturity`). `None` means we cannot tell, and an action + /// the node would accept must not be refused on a guess. + pub transfer_height: Option, + pub current_height: Option, /// The `reveal_txid` stamped on the bid commitment row (if any). pub reveal_txid: Option, /// Status of the local tx_draft matching `reveal_txid` (if one exists). @@ -711,6 +719,14 @@ pub(crate) fn find_name_action_context( let bid_value_doos = bid.as_ref().map(|b| b.bid_value_doos); let lockup_value_doos = bid.as_ref().map(|b| b.lockup_value_doos); + let tracked_row = queries::get_tracked_name_state(conn, profile_id, name).unwrap_or(None); + let transfer_height = tracked_row + .as_ref() + .and_then(|t| t.transfer_height) + .filter(|h| *h > 0); + let current_height = + crate::commands::read::estimate_persisted_height(conn, profile_id).unwrap_or(None); + Ok(NameActionContext { has_bid_commitment: bid.is_some(), has_bid_coin: bid_coin.is_some(), @@ -719,6 +735,8 @@ pub(crate) fn find_name_action_context( owner_covenant_type: owner_cov_type, name_height: nh, transfer_has_items: transfer, + transfer_height, + current_height, existing_bid_count, has_pending_open, pending_broadcast_action, @@ -1124,14 +1142,35 @@ pub(crate) fn build_name_action_capabilities( }, }; + // hsd refuses a FINALIZE until `transfer + transfer_lockup` blocks have + // passed (`bad-finalize-maturity`). A transaction built now lands in the + // next block, so the lockup is over once `tip + 1` reaches that height. + // With either height unknown we say nothing: refusing an action the node + // would accept is its own kind of wrong. + let blocks_until_finalize = action_ctx + .transfer_height + .zip(action_ctx.current_height) + .map(|(transfer, tip)| { + let ready_at = transfer + network.name_params().transfer_lockup as i64; + (ready_at - (tip + 1)).max(0) + }); + let finalize_matured = blocks_until_finalize.map(|b| b == 0).unwrap_or(true); + let can_finalize = NameActionCapability { - allowed: can_spend_as_owner && action_ctx.transfer_has_items.unwrap_or(false), + allowed: can_spend_as_owner + && action_ctx.transfer_has_items.unwrap_or(false) + && finalize_matured, reason: if !owns_name { Some("wallet does not control this name".into()) } else if !action_ctx.transfer_has_items.unwrap_or(false) { Some("name is not in TRANSFER state".into()) } else { - None + blocks_until_finalize.filter(|b| *b > 0).map(|blocks| { + format!( + "the transfer is still locked for {blocks} more block{}", + if blocks == 1 { "" } else { "s" } + ) + }) }, }; @@ -3521,6 +3560,8 @@ mod tests { redeemable_reveal_count: 0, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, reveal_txid: None, reveal_draft_status: None, bid_value_doos: None, @@ -4599,6 +4640,83 @@ mod tests { assert!(o.spend_locked); } + /// hsd refuses a FINALIZE until `transfer + transferLockup` blocks have + /// passed (`bad-finalize-maturity`, chain.js). Offering the button before + /// then sends the user at a transaction the node throws away — the same + /// fault the ownership actions had before REGISTER, one stage along. + #[test] + fn finalize_waits_out_the_transfer_lockup() { + // Regtest locks a transfer for 10 blocks. Transferred at 800, so a + // finalize is valid in block 810 — i.e. once the tip reaches 809. + let mid_lockup = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_TRANSFER as i64), + transfer_has_items: Some(true), + transfer_height: Some(800), + current_height: Some(805), + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + "TRANSFER".into(), + "TRANSFER", + None, + &mid_lockup, + true, + false, + None, + Network::Regtest, + ); + assert!(!caps.can_finalize.allowed, "still inside the lockup"); + let reason = caps.can_finalize.reason.unwrap_or_default(); + assert!( + reason.contains("4"), + "the reason must say how many blocks are left, got {reason:?}" + ); + + let matured = NameActionContext { + current_height: Some(809), + ..mid_lockup + }; + let caps = build_name_action_capabilities( + "n".into(), + "TRANSFER".into(), + "TRANSFER", + None, + &matured, + true, + false, + None, + Network::Regtest, + ); + assert!(caps.can_finalize.allowed, "the next block may carry it"); + } + + /// Without heights we cannot say, and refusing an action the node would + /// accept is its own kind of wrong. A transfer with no recorded height + /// stays offered. + #[test] + fn finalize_is_not_blocked_when_the_lockup_is_unknown() { + 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::Regtest, + ); + assert!(caps.can_finalize.allowed); + } + /// `LostNeedsRedeem` is reached two ways and only one is a loss. A wallet /// that outbid itself owns the name and holds its own losing reveals, and /// "Your bid lost" is false for it — on a name it just registered. @@ -4755,6 +4873,8 @@ mod tests { redeemable_reveal_count: 1, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, ..ctx_default() }; let caps = build_name_action_capabilities( @@ -4831,6 +4951,8 @@ mod tests { redeemable_reveal_count: 2, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, ..ctx_default() }; let caps = build_name_action_capabilities( @@ -4860,6 +4982,8 @@ mod tests { redeemable_reveal_count: 0, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, ..ctx_default() }; let caps = build_name_action_capabilities( diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index eee3c97c..fdc09320 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1960,6 +1960,12 @@ pub struct TrackedNameRow { /// network renewal window vs. a persisted height estimate) instead of /// leaving the expiry alarm silent for lack of live node stats. pub renewal_height: Option, + /// The block the name's TRANSFER was recorded in + /// (`getnameinfo().info.transfer`), or `None`/0 when none is pending. + /// hsd refuses a FINALIZE until `transfer + transfer_lockup` blocks have + /// passed, so the capability gate needs it to avoid offering one the node + /// will throw away. + pub transfer_height: Option, } /// Resolve the name for a given nameHash (hex) under a profile. Returns None if @@ -1990,7 +1996,7 @@ pub fn get_tracked_name_state( ) -> Result, AppError> { let row = conn .query_row( - "SELECT name, state, owner_address, raw_json, renewal_height + "SELECT name, state, owner_address, raw_json, renewal_height, transfer_height FROM tracked_name_states WHERE wallet_profile_id = ?1 AND name = ?2", params![profile_id, name], @@ -2001,6 +2007,7 @@ pub fn get_tracked_name_state( owner_address: row.get(2)?, raw_json: row.get(3)?, renewal_height: row.get(4)?, + transfer_height: row.get(5)?, }) }, ) diff --git a/src-tauri/src/tests/name_capabilities_tests.rs b/src-tauri/src/tests/name_capabilities_tests.rs index 6b27e19a..c27ee826 100644 --- a/src-tauri/src/tests/name_capabilities_tests.rs +++ b/src-tauri/src/tests/name_capabilities_tests.rs @@ -48,6 +48,8 @@ fn ctx( redeemable_reveal_count: 0, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, reveal_txid, reveal_draft_status, bid_value_doos, @@ -756,6 +758,8 @@ fn cap_closed_phase_can_redeem_lost_bid() { redeemable_reveal_count: 1, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, ..ctx( false, false, true, false, None, None, None, 0, false, None, None, None, ) @@ -783,6 +787,8 @@ fn cap_closed_phase_cannot_redeem_when_the_only_reveal_won() { redeemable_reveal_count: 0, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, ..ctx( false, false, true, false, None, None, None, 0, false, None, None, None, ) @@ -815,6 +821,8 @@ fn cap_closed_phase_can_redeem_own_losing_bids_while_owning_the_name() { redeemable_reveal_count: 2, redeemable_value_doos: 0, owner_spend_in_flight: false, + transfer_height: None, + current_height: None, ..ctx( false, false, true, false, None, None, None, 0, false, None, None, None, ) From bd72293056d59d66a4d27350b4f05926e9275618 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 14:56:29 +0200 Subject: [PATCH 4/9] fix(names): a batched owner spend is still an owner spend, and read the live tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both found running a transfer end to end on a live wallet. The modal's Transfer button persists its draft as `batch-transfer`, and the owner-spend list added yesterday only knew the singular names. So the moment the transfer went out, ownership collapsed again — the exact fault that flag exists to close, reopened for every action the batch builders emit. The match now strips a `batch-` prefix, which keeps `batch-bid` and `batch-redeem` correctly saying nothing about ownership. The transfer-lockup gate read its tip from `estimate_persisted_height`, which is deliberately conservative and does not age at all on regtest — so with the chain ten blocks ahead of the last sync it counted ten blocks of lockup that had already passed, and refused a FINALIZE the node would have accepted. That is the failure the gate's own comment promised to avoid. It now prefers the live tip, fetched once per call rather than once per name, and falls back to the estimate when no synced node answers. --- src-tauri/src/commands/names.rs | 30 +++++++- .../src/tests/names_action_context_tests.rs | 77 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index eaefe987..f4d0104c 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -692,8 +692,12 @@ pub(crate) fn find_name_action_context( // about ownership. let owner_spend_in_flight = owner_coin.is_none() && pending_actions.iter().any(|a| { + // A batch draft performs the same covenant for several names at + // once and records itself as `batch-`; the owner coin is + // spent either way. Matching only the singular names reopened this + // very bug for the modal's Transfer button, which batches. matches!( - a.as_str(), + a.strip_prefix("batch-").unwrap_or(a), "register" | "update" | "transfer" @@ -786,7 +790,8 @@ pub async fn get_name_action_capabilities( Some(id) => id, None => return Ok(conservative_capabilities(&name, "no active wallet profile")), }; - evaluate_name_action_capabilities(&state, name, &profile_id).await + let live_tip = crate::commands::read::node_tip_height_if_synced(&state).await; + evaluate_name_action_capabilities(&state, name, &profile_id, live_tip).await } /// Max names accepted by [`get_names_action_capabilities`] per call. Each @@ -828,9 +833,13 @@ pub async fn get_names_action_capabilities( .collect()); } }; + // Fetched once for the whole batch, not once per name: the only thing it + // is needed for is the transfer-lockup countdown, and a stale tip there + // refuses a FINALIZE the node would accept. + let live_tip = crate::commands::read::node_tip_height_if_synced(&state).await; let mut out = Vec::with_capacity(names.len()); for name in names { - out.push(evaluate_name_action_capabilities(&state, name, &profile_id).await?); + out.push(evaluate_name_action_capabilities(&state, name, &profile_id, live_tip).await?); } Ok(out) } @@ -843,6 +852,7 @@ async fn evaluate_name_action_capabilities( state: &State<'_, AppState>, name: String, profile_id: &str, + live_tip: Option, ) -> Result { // Resolved once for both branches below: the expiry warning threshold and // the renewal window are both per-network. @@ -897,6 +907,13 @@ async fn evaluate_name_action_capabilities( }; let stats = name_info.get("info").and_then(|i| i.get("stats")); + // The persisted estimate is deliberately conservative — on regtest + // it does not age at all — and the transfer-lockup gate is the one + // consumer where a stale tip refuses an action the node accepts. + let action_ctx = NameActionContext { + current_height: live_tip.or(action_ctx.current_height), + ..action_ctx + }; let NameOwnership { owns_name, spend_locked, @@ -953,6 +970,13 @@ async fn evaluate_name_action_capabilities( .as_deref() .map(|s| s.to_uppercase()) .unwrap_or_default(); + // The persisted estimate is deliberately conservative — on regtest + // it does not age at all — and the transfer-lockup gate is the one + // consumer where a stale tip refuses an action the node accepts. + let action_ctx = NameActionContext { + current_height: live_tip.or(action_ctx.current_height), + ..action_ctx + }; let NameOwnership { owns_name, spend_locked, diff --git a/src-tauri/src/tests/names_action_context_tests.rs b/src-tauri/src/tests/names_action_context_tests.rs index ddf95834..b0b09ea5 100644 --- a/src-tauri/src/tests/names_action_context_tests.rs +++ b/src-tauri/src/tests/names_action_context_tests.rs @@ -624,6 +624,83 @@ fn find_name_action_context_knows_the_owner_coin_is_only_spent_in_flight() { ); } +/// Caught running a transfer end to end: the modal's Transfer button persists +/// its draft as `batch-transfer`, and the owner-spend list only knew the +/// singular names. So the moment a transfer went out, ownership collapsed +/// again — the exact fault this flag was added to close, reopened for every +/// action the batch builders emit. +#[test] +fn find_name_action_context_recognises_a_batched_owner_spend() { + let conn = test_db(); + seed_profile(&conn); + seed_derived_address(&conn, ADDRESS, 0, 0); + let nh_hex = hex::encode(crate::noncustodial::names::hash_name(NAME).unwrap()); + let cov = format!(r#"{{"type":{},"items":["{nh_hex}"]}}"#, sync::COV_REGISTER); + seed_tracked_utxo( + &conn, + "owner", + 0, + ADDRESS, + sync::COV_REGISTER as i64, + Some(&cov), + ); + conn.execute( + "UPDATE tracked_utxos SET spent_by_txid = 'spent' WHERE txid = 'owner'", + [], + ) + .unwrap(); + seed_tracked_name_state( + &conn, + NAME, + &nh_hex, + "CLOSED", + Some("owner"), + Some(0), + Some(779), + ); + + seed_draft(&conn, "d-bt", "batch-transfer", NAME); + db::queries::update_tx_draft_status(&conn, "d-bt", "broadcasted", None, Some("bttx")).unwrap(); + let ctx = find_name_action_context(&conn, PROFILE, NAME, Some(779)).unwrap(); + assert!( + ctx.owner_spend_in_flight, + "a batched transfer spends the owner coin just as a single one does" + ); + + // And a batched action that does NOT touch the owner coin still says + // nothing about ownership. + let conn2 = test_db(); + seed_profile(&conn2); + seed_derived_address(&conn2, ADDRESS, 0, 0); + seed_tracked_utxo( + &conn2, + "owner", + 0, + ADDRESS, + sync::COV_REGISTER as i64, + Some(&cov), + ); + conn2 + .execute( + "UPDATE tracked_utxos SET spent_by_txid = 'spent' WHERE txid = 'owner'", + [], + ) + .unwrap(); + seed_tracked_name_state( + &conn2, + NAME, + &nh_hex, + "CLOSED", + Some("owner"), + Some(0), + Some(779), + ); + seed_draft(&conn2, "d-br", "batch-redeem", NAME); + db::queries::update_tx_draft_status(&conn2, "d-br", "broadcasted", None, Some("brtx")).unwrap(); + let ctx2 = find_name_action_context(&conn2, PROFILE, NAME, Some(779)).unwrap(); + assert!(!ctx2.owner_spend_in_flight); +} + /// 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 e571249bbd723e1cf44545792047e544ca8088a5 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 15:07:13 +0200 Subject: [PATCH 5/9] fix(names): a pending transfer is a task, not a phase that never arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a live wallet mid-transfer: the modal's detail panel read "Transfer in progress (height 803)" while the status beside it read "Owned". Both were derived from the node's answer; only one of them asked the right question. hsd has six name states — OPENING, LOCKED, BIDDING, REVEAL, CLOSED, REVOKED (`namestate.js`) — and TRANSFER is not among them. A transfer leaves the state at CLOSED and shows itself through `info.transfer`. So the `"TRANSFER" =>` arm of the task-state derivation could never fire against a real node, and every name being transferred fell through to "no urgent action" — on a name whose one remaining action is to finalize it. The task now reads the transfer the node actually reports, and sits behind the renewal alarm but ahead of everything quiet: losing the name outranks completing a transfer of it, and nothing else does. Three tests asserted the old arm by passing a phase string the node does not send. They now describe a transfer the way one arrives. --- src-tauri/src/commands/names.rs | 91 ++++++++++++++++++- .../src/tests/auction_capabilities_tests.rs | 34 ++++++- .../src/tests/name_capabilities_tests.rs | 33 ++++++- 3 files changed, 146 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index f4d0104c..13f5062b 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -1305,6 +1305,7 @@ pub(crate) fn build_name_action_capabilities( action_ctx.owner_covenant_type, days_until_expire, action_ctx.has_pending_open, + transfer_pending, action_ctx.reveal_txid.as_deref(), action_ctx.reveal_draft_status.as_deref(), network, @@ -1512,6 +1513,11 @@ pub fn derive_auction_task_state( owner_covenant_type: Option, days_until_expire: Option, has_pending_open: bool, + // `transfer_pending`: a TRANSFER is recorded for this name. NOT derivable + // from `phase` — hsd's name states are OPENING / LOCKED / BIDDING / + // REVEAL / CLOSED / REVOKED (`namestate.js`), and a transfer leaves the + // state at CLOSED, signalling itself through `info.transfer` instead. + transfer_pending: bool, reveal_txid: Option<&str>, reveal_draft_status: Option<&str>, network: Network, @@ -1580,6 +1586,12 @@ pub fn derive_auction_task_state( if already_registered { if expiring_soon { AuctionTaskState::ExpiringSoon + } else if transfer_pending { + // Losing the name outranks completing a transfer of + // it, so this sits behind the renewal alarm — but + // ahead of everything quiet, because finalizing is the + // one thing the name is waiting on. + AuctionTaskState::TransferPendingFinalize } else if has_reveal_coin { // Registered, and still holding a REVEAL coin. The // winning one was spent by that REGISTER, so whatever @@ -1614,7 +1626,8 @@ pub fn derive_auction_task_state( AuctionTaskState::OwnedNoUrgentAction } } - "TRANSFER" => AuctionTaskState::TransferPendingFinalize, + // No `"TRANSFER"` arm: hsd has no such state. A transfer is handled + // inside CLOSED above, where the node actually reports it. "REVOKED" => AuctionTaskState::UnavailableOther, _ => { if owns_name { @@ -3619,6 +3632,7 @@ mod tests { owner_covenant_type, days_until_expire, has_pending_open, + false, reveal_txid, reveal_draft_status, Network::Main, @@ -4047,19 +4061,25 @@ mod tests { #[test] fn derive_transfer() { + // The node reports CLOSED for a name being transferred — hsd has no + // TRANSFER state — and signals the transfer separately, so this goes + // through the full derivation rather than the `derive` helper, which + // has no transfer to give it. assert_eq!( - derive( - "TRANSFER", + derive_auction_task_state( + "CLOSED", true, false, false, false, true, - Some(9), + Some(COV_TRANSFER as i64), None, false, + true, None, - None + None, + Network::Main, ), AuctionTaskState::TransferPendingFinalize ); @@ -4664,6 +4684,67 @@ mod tests { assert!(o.spend_locked); } + /// Reported live, mid-transfer: the modal's detail panel said "Transfer in + /// progress (height 803)" while the status beside it said "Owned". + /// + /// hsd has six name states — OPENING, LOCKED, BIDDING, REVEAL, CLOSED, + /// REVOKED (`namestate.js`) — and TRANSFER is not among them. A transfer + /// is signalled by `info.transfer != 0`, with the state left at CLOSED. + /// So the `"TRANSFER" =>` arm never fired against a real node and every + /// name mid-transfer fell through to "no urgent action" — on a name whose + /// one remaining action is to finalize. + #[test] + fn a_pending_transfer_is_the_task_even_though_hsd_calls_the_state_closed() { + let ctx = NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_TRANSFER as i64), + transfer_has_items: Some(true), + transfer_height: Some(803), + current_height: Some(813), + ..ctx_default() + }; + let caps = build_name_action_capabilities( + "n".into(), + // Exactly what the node reports for a name being transferred. + "CLOSED".into(), + "CLOSED", + None, + &ctx, + true, + false, + None, + Network::Regtest, + ); + assert!( + matches!(caps.task_state, AuctionTaskState::TransferPendingFinalize), + "got {:?}", + caps.task_state + ); + } + + /// Losing the name outranks completing a transfer of it. + #[test] + fn an_expiring_name_outranks_its_pending_transfer() { + 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(), + "CLOSED".into(), + "CLOSED", + None, + &ctx, + true, + false, + Some(1.0), + Network::Regtest, + ); + assert!(matches!(caps.task_state, AuctionTaskState::ExpiringSoon)); + } + /// hsd refuses a FINALIZE until `transfer + transferLockup` blocks have /// passed (`bad-finalize-maturity`, chain.js). Offering the button before /// then sends the user at a transaction the node throws away — the same diff --git a/src-tauri/src/tests/auction_capabilities_tests.rs b/src-tauri/src/tests/auction_capabilities_tests.rs index f0ba8076..47a31e6e 100644 --- a/src-tauri/src/tests/auction_capabilities_tests.rs +++ b/src-tauri/src/tests/auction_capabilities_tests.rs @@ -31,6 +31,7 @@ fn state_no_owner( None, None, false, + false, None, None, Network::Main, @@ -55,6 +56,7 @@ fn state_registered( Some(6), None, false, + false, None, None, Network::Main, @@ -79,6 +81,7 @@ fn state_unregistered( Some(4), None, false, + false, None, None, Network::Main, @@ -97,6 +100,7 @@ fn state_registered_days(phase: &str, days: Option) -> AuctionTaskState { Some(6), days, false, + false, None, None, Network::Main, @@ -144,6 +148,7 @@ fn available_with_pending_open_yields_waiting_for_bidding() { None, None, true, + false, None, None, Network::Main, @@ -163,6 +168,7 @@ fn empty_phase_with_pending_open_yields_waiting_for_bidding() { None, None, true, + false, None, None, Network::Main, @@ -184,6 +190,7 @@ fn available_without_pending_open_still_yields_available_to_open() { None, None, false, + false, None, None, Network::Main, @@ -255,6 +262,7 @@ fn reveal_state( None, None, false, + false, reveal_txid, reveal_draft_status, Network::Main, @@ -362,8 +370,27 @@ fn closed_without_owner_or_reveal_yields_owned_no_urgent_action() { } #[test] -fn transfer_phase_yields_transfer_pending_finalize() { - let state = state_no_owner("TRANSFER", true, false, false, false); +fn a_recorded_transfer_yields_transfer_pending_finalize() { + // Not a phase. hsd's states are OPENING / LOCKED / BIDDING / REVEAL / + // CLOSED / REVOKED; a transfer leaves the state at CLOSED and shows + // itself through `info.transfer`. Asserting on a "TRANSFER" phase pinned + // a string the node never sends, and the real case fell through to + // "no urgent action" on a name waiting to be finalized. + let state = derive_auction_task_state( + "CLOSED", + true, + false, + false, + false, + true, + Some(crate::noncustodial::sync::COV_TRANSFER as i64), + None, + false, + true, + None, + None, + Network::Main, + ); assert_eq!(state, AuctionTaskState::TransferPendingFinalize); } @@ -436,6 +463,7 @@ fn explorer_owned_without_owner_coin_within_threshold_yields_expiring_soon() { None, Some(5.0), false, + false, None, None, Network::Main, @@ -456,6 +484,7 @@ fn won_unregistered_within_threshold_still_needs_register_first() { Some(4), Some(5.0), false, + false, None, None, Network::Main, @@ -476,6 +505,7 @@ fn unowned_closed_within_threshold_is_not_expiring_soon() { None, Some(5.0), false, + false, None, None, Network::Main, diff --git a/src-tauri/src/tests/name_capabilities_tests.rs b/src-tauri/src/tests/name_capabilities_tests.rs index c27ee826..da9e3912 100644 --- a/src-tauri/src/tests/name_capabilities_tests.rs +++ b/src-tauri/src/tests/name_capabilities_tests.rs @@ -73,6 +73,7 @@ fn task_state_available_no_pending_open() { None, None, false, + false, None, None, Network::Main, @@ -92,6 +93,7 @@ fn task_state_available_with_pending_open() { None, None, true, // has_pending_open + false, None, None, Network::Main, @@ -111,6 +113,7 @@ fn task_state_empty_phase_treated_as_available() { None, None, false, + false, None, None, Network::Main, @@ -130,6 +133,7 @@ fn task_state_opening_phase() { None, None, false, + false, None, None, Network::Main, @@ -149,6 +153,7 @@ fn task_state_bidding_with_commitment() { None, None, false, + false, None, None, Network::Main, @@ -170,6 +175,7 @@ fn task_state_bidding_without_commitment() { None, None, false, + false, None, None, Network::Main, @@ -189,6 +195,7 @@ fn task_state_reveal_no_commitment_returns_unavailable() { None, None, false, + false, None, None, Network::Main, @@ -208,6 +215,7 @@ fn task_state_reveal_with_broadcasted_draft() { None, None, false, + false, None, Some("broadcasted"), Network::Main, @@ -227,6 +235,7 @@ fn task_state_reveal_with_broadcast_pending_draft() { None, None, false, + false, None, Some("broadcast_pending"), Network::Main, @@ -246,6 +255,7 @@ fn task_state_reveal_with_confirmed_draft() { None, None, false, + false, None, Some("confirmed"), Network::Main, @@ -266,6 +276,7 @@ fn task_state_reveal_with_dropped_draft_and_unspent_bid_coin() { None, None, false, + false, None, Some("dropped"), Network::Main, @@ -286,6 +297,7 @@ fn task_state_reveal_with_txid_and_spent_bid_coin() { None, None, false, + false, Some("abc123"), None, Network::Main, @@ -305,6 +317,7 @@ fn task_state_closed_owns_name_unregistered() { Some(2), // COV_OPEN < COV_REGISTER None, false, + false, None, None, Network::Main, @@ -324,6 +337,7 @@ fn task_state_closed_owns_name_already_registered() { Some(6), // COV_REGISTER None, false, + false, None, None, Network::Main, @@ -343,6 +357,7 @@ fn task_state_closed_owns_name_registered_expiring_soon() { Some(6), Some(15.0), // days_until_expire = 15 (below 30-day threshold) false, + false, None, None, Network::Main, @@ -363,6 +378,7 @@ fn task_state_closed_owns_name_no_coin_synced() { None, None, false, + false, None, None, Network::Main, @@ -382,6 +398,7 @@ fn task_state_closed_lost_has_reveal_coin() { None, None, false, + false, None, None, Network::Main, @@ -390,17 +407,20 @@ fn task_state_closed_lost_has_reveal_coin() { } #[test] -fn task_state_transfer_phase() { +fn task_state_recorded_transfer() { + // hsd leaves the state at CLOSED while a transfer is pending — there is + // no TRANSFER state — so the transfer flag is what decides this. let state = derive_auction_task_state( - "TRANSFER", - false, - false, + "CLOSED", + true, false, false, false, - None, + true, + Some(COV_TRANSFER as i64), None, false, + true, None, None, Network::Main, @@ -420,6 +440,7 @@ fn task_state_revoked_phase() { None, None, false, + false, None, None, Network::Main, @@ -439,6 +460,7 @@ fn task_state_unknown_phase_owned() { None, None, false, + false, None, None, Network::Main, @@ -458,6 +480,7 @@ fn task_state_unknown_phase_not_owned() { None, None, false, + false, None, None, Network::Main, From 67ae6757d283d2464bf91955c3b216b98a741045 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 15:14:50 +0200 Subject: [PATCH 6/9] fix(names): two more ways a pending transfer could be lost or refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from a live wallet mid-transfer, with six buttons live at once. Consensus says two of them should not have been. Renew is Update's twin, and only Update was gated. hsd's RENEW handler runs `ns.setTransfer(0)` exactly as UPDATE does (`chain.js`), so "extend my registration" ends a transfer in flight and says nothing about transfers. Cancel first and the renewal is one click away; the other order loses the transfer silently. Transfer was offered on a name already being transferred. A TRANSFER coin may become an UPDATE, RENEW, FINALIZE or REVOKE and nothing else (`rules.verifyCovenants`), so a second one is a transaction the node refuses. "Sell with payment" builds a transfer and disappears with it, having been gated on the same capability all along. Revoke stays: consensus allows it from a TRANSFER coin, and destroying the name is what that button says it does — no surprise to protect the user from. --- src-tauri/src/commands/names.rs | 77 ++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 13f5062b..a79b5889 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -1155,12 +1155,17 @@ pub(crate) fn build_name_action_capabilities( }, }; + // A TRANSFER coin may go to UPDATE, RENEW, FINALIZE or REVOKE — never to + // another TRANSFER (`rules.verifyCovenants`). Offering a second one sends + // the user at a transaction the node refuses. let can_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("a transfer is already pending — finalize or cancel it first".into()) } else { None }, @@ -1214,12 +1219,18 @@ pub(crate) fn build_name_action_capabilities( }, }; + // Renew is Update's twin here: hsd's RENEW handler runs `ns.setTransfer(0)` + // just as UPDATE does (`chain.js`), so extending the registration ends a + // transfer in flight without saying so. Cancel the transfer first and the + // renewal is one click away; the reverse order loses the transfer silently. let can_renew = 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 — renewing would cancel it".into()) } else { None }, @@ -5319,6 +5330,68 @@ mod tests { assert!(caps.can_cancel_transfer.allowed); } + /// Renew is the second way to lose a transfer without being told. hsd's + /// RENEW handler runs `ns.setTransfer(0)` exactly as UPDATE does + /// (`chain.js`), so "extend my registration" quietly ends a transfer in + /// flight. Update was gated for this; its twin was not. + #[test] + fn renew_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(), + "CLOSED".into(), + "CLOSED", + None, + &ctx, + true, + false, + None, + Network::Main, + ); + assert!(!caps.can_renew.allowed); + assert_eq!( + caps.can_renew.reason.as_deref(), + Some("a transfer is pending — renewing would cancel it") + ); + } + + /// A second transfer is not a thing hsd allows: a TRANSFER coin may go to + /// UPDATE, RENEW, FINALIZE or REVOKE and nothing else, so offering + /// Transfer here sends the user at a transaction the node refuses. + #[test] + fn transfer_is_refused_while_a_transfer_is_already_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(), + "CLOSED".into(), + "CLOSED", + None, + &ctx, + true, + false, + None, + Network::Main, + ); + assert!(!caps.can_transfer.allowed); + assert_eq!( + caps.can_transfer.reason.as_deref(), + Some("a transfer is already pending — finalize or cancel it first") + ); + // Revoking stays available: consensus allows it and it is not a + // surprise, it is the button that destroys the name. + assert!(caps.can_revoke.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 From 2123813db2ac3b1154a1e2096af5f2d973784fc4 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 15:36:29 +0200 Subject: [PATCH 7/9] test(paid-swaps): pin the case the buyer-address exclusion exists to catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written while reading the paid-swap flow end to end. I expected `find_payment_output` to match the buyer's address and reported it to myself as a hole; running it showed the opposite — it *excludes* that address and takes any other output worth at least the price, which is why a transaction paying only the buyer verifies as unpaid. The helper had that covered; the command around it did not. This pins it where the seller actually calls it. --- src-tauri/src/tests/paid_swaps_cmd_tests.rs | 33 +++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src-tauri/src/tests/paid_swaps_cmd_tests.rs b/src-tauri/src/tests/paid_swaps_cmd_tests.rs index 1a152282..d62b06f5 100644 --- a/src-tauri/src/tests/paid_swaps_cmd_tests.rs +++ b/src-tauri/src/tests/paid_swaps_cmd_tests.rs @@ -212,6 +212,39 @@ fn create_offer(conn: &rusqlite::Connection, name: &str, buyer: &str, price_doos .unwrap(); } +/// A transaction whose every output goes back to the buyer paid nobody. +/// `find_payment_output` works by exclusion — any output NOT at the buyer's +/// address and worth at least the price counts — so this is the case that +/// exclusion exists to catch, and it is worth pinning at the command level +/// rather than only on the helper. +#[tokio::test] +async fn claim_rejects_a_transaction_whose_outputs_all_return_to_the_buyer() { + let mut server = mockito::Server::new_async().await; + let _tx = server + .mock("GET", "/tx/nopay") + .with_status(200) + .with_body( + r#"{"hash":"nopay","confirmations":7, + "outputs":[ + {"address":"hs1qbuyer","value":5000000} + ]}"#, + ) + .create_async() + .await; + + let url = server.url(); + let app = app_with(|conn| { + create_offer(conn, "sale", "hs1qbuyer", 1_000_000); + db::queries::set_setting(conn, "node_rpc_url", &url).unwrap(); + }); + + let result = claim_paid_transfer(app.state(), "sale".into(), "nopay".into()) + .await + .expect("the tx exists, so the command answers rather than erroring"); + assert!(!result.verified); + assert_eq!(result.paid_doos, 0); +} + #[tokio::test] async fn claim_verifies_and_marks_claimed_on_valid_payment() { let mut server = mockito::Server::new_async().await; From 53a1e0b3de434c299d346dd66c8be5cafe81363a Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 15:42:51 +0200 Subject: [PATCH 8/9] feat(names): withdraw the paid-swap buttons, and write down what one needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading the flow end to end to answer "why is Buy with payment offered to the sender" turned up that the question has no good answer: the shape cannot work. FINALIZE spends the coin the TRANSFER created, and that coin stays at the seller's address — hsd requires REGISTER -> TRANSFER to keep it — so the seller signs the finalize. `build_finalize_with_payment_draft` agrees, resolving the owner coin and failing without it. The button labelled "Buy with payment" could only ever be pressed by the party selling, who has nobody to pay. And nothing was atomic. Every input is signed `sighash::ALL`, which leaves no room for a counterparty to add their payment to a finished transaction, so "one transaction" meant one wallet funding both halves of its own trade. A real swap needs the flags Shakedex uses, and a signer willing to produce them — a decision, not a flag to add quietly. So the two entry points go. The claim panel stays: it renders only when an offer exists, and an offer recorded before this change should still be claimable. The backend commands stay with it. `docs/specs/2026-09-21-paid-name-swaps.md` carries the finding and what a working implementation would need, including the third thing found on the way: the claim check works by excluding the buyer's address rather than checking the seller's, because the offer never records one. --- CHANGELOG.md | 1 + docs/specs/2026-09-21-paid-name-swaps.md | 88 ++++++++++++ docs/specs/README.md | 1 + src/components/NameActionsModal.tsx | 18 --- .../__tests__/name-modal-sections.test.tsx | 40 ++++++ .../name-actions/OwnershipActions.tsx | 133 +----------------- 6 files changed, 135 insertions(+), 146 deletions(-) create mode 100644 docs/specs/2026-09-21-paid-name-swaps.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2178f31c..2bf7a7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - **The name status no longer contradicts the modal it opens.** The Owned Names table printed the auction phase while the modal printed the task, so a row reading "Closed" opened a modal headed "Won — Register Now". The table now shows the same summary the auctions list and the modal do, and defers to what is in flight when a transaction of yours is waiting for a block. The phase was nearly a constant down that column anyway: every name you own has a closed auction. - **Reclaiming your own losing bids is no longer described as losing.** Outbidding yourself and winning leaves you owning the name and holding your own losing reveals — the ordinary outcome of bidding more than once. The wallet greeted it with a red "Lost — Redeem Now" on a name just registered. The state was right; only its description was written for the other way into it. - **Broadcasting your own transaction no longer reads as losing the name.** hsd drops a coin from its unspent set as soon as a mempool transaction spends it, so the instant a register or transfer went out the wallet stopped finding the owner coin and concluded it did not own the name — which, on a closed auction still holding reveals, is the shape of a loss. Ownership now survives an unconfirmed spend of your own; acting on the name stays blocked until the block lands, which was the half that was already right. +- **The paid-swap buttons are gone.** "Sell with payment" and "Buy with payment" offered a trade the code could not make. Finalizing a transfer spends the coin the TRANSFER created, and that coin stays at the seller's address — so only the seller can finalize, and the button labelled "Buy" could never be pressed by a buyer. Nor was anything atomic: every input is signed with a flag that forbids a counterparty from completing the transaction, so "pay and receive in one transaction" meant one wallet funding both halves of its own trade. Selling a name still works the ordinary way — agree a price, Transfer, Finalize — and an offer recorded before this change can still be claimed. What a real atomic swap needs is written down in `docs/specs/2026-09-21-paid-name-swaps.md`. - **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. diff --git a/docs/specs/2026-09-21-paid-name-swaps.md b/docs/specs/2026-09-21-paid-name-swaps.md new file mode 100644 index 00000000..69d230cc --- /dev/null +++ b/docs/specs/2026-09-21-paid-name-swaps.md @@ -0,0 +1,88 @@ +# Paid name swaps + +**Status: not implemented. The UI entry points were withdrawn on +2026-09-21; the backend commands remain, and an offer already recorded can +still be claimed.** + +## 1. Summary + +Selling a Handshake name for HNS should be one transaction: the buyer pays and +the name moves, or neither happens. The wallet shipped two buttons — "Sell with +payment" and "Buy with payment" — and a seller-side offer record, and the shape +they implement cannot do that. This spec says what was there, why it could not +work, and what a real implementation needs, so the next attempt starts from the +consensus rules rather than from the buttons. + +## 2. Terms + +- **Seller** — holds the name and its owner coin. +- **Buyer** — pays HNS and should end up holding the name. +- **TRANSFER coin** — the output a TRANSFER covenant creates. It carries the + recipient inside the covenant (`items[2]` = address version, `items[3]` = + address hash) and **stays at the seller's own address**: hsd requires + REGISTER → TRANSFER to keep the address (`rules.verifyCovenants`). +- **Atomic** — one transaction that either performs both halves or is invalid. + +## 3. Why the withdrawn shape could not work + +**W1 — Only the seller can finalize.** FINALIZE spends the TRANSFER coin, and +that coin sits at the seller's address, so the seller signs it. The wallet +agrees: `build_finalize_with_payment_draft` resolves the owner coin through +`owner_coin_and_state` and fails without it. So the button labelled "Buy with +payment" could only ever be pressed by the party selling — who has nobody to +pay. + +**W2 — Nothing was atomic.** Every input in `noncustodial::actions` is signed +`sighash::ALL`. A counterparty cannot add inputs or outputs to a finished +transaction under that flag, so "finalize and pay in one transaction" means one +wallet funding both halves out of its own coins. There is no exchange. + +**W3 — The claim verifies less than it says.** `claim_paid_transfer` documents +itself as checking "a P2WPKH output to the seller's address with value >= +price". `find_payment_output` works by exclusion instead: any output **not** at +the buyer's address, worth at least the price, counts. It cannot check the +seller's address because `paid_swap_offers` never records one. A payment to any +third party satisfies it. Not exploitable on its own — the seller supplies the +txid — but "verified" overstates the evidence. + +## 4. What a real implementation needs + +**R1 — Swap sighash flags.** The seller pre-signs the FINALIZE with a sighash +type that leaves room for the buyer to add their payment: this is what +Shakedex does (`SINGLEREVERSE` + `ANYONECANPAY`). `noncustodial::tx::sighash` +would need those variants, and the signer would need to be willing to produce +them — a wallet that signs `ANYONECANPAY` is signing something a stranger can +complete, which is a decision to make deliberately, not a flag to add quietly. + +**R2 — An offer is a signed artefact, not a DB row.** What the seller publishes +must be the pre-signed input plus the price, so a buyer can verify and complete +it without trusting the seller's wallet. The current `paid_swap_offers` table +is local bookkeeping and cannot travel. + +**R3 — The seller's payout address is part of the offer.** Without it no check +can answer "was I paid" (W3). + +**R4 — The buyer's side is a fill, not a finalize.** The buyer takes the +seller's pre-signed transaction, adds funding inputs and the payment output, +and broadcasts. There is no separate "finalize with payment" command for them. + +## 5. Explicitly not enforced + +- Nothing stops a seller and buyer arranging payment off-chain and using a + plain Transfer + Finalize. That works today and is what the wallet supports. +- Removing the UI does not remove the backend commands + (`create_paid_swap_offer`, `claim_paid_transfer`, + `build_finalize_with_payment_draft`). They stay so an offer recorded before + this change can still be claimed through `PaidSwapClaim`, which renders only + when one exists. + +## 6. Pointers + +- `src/components/name-actions/OwnershipActions.tsx` — where the two buttons + and their forms were. +- `src/components/name-actions/PaidSwapClaim.tsx` — the claim panel, kept. +- `src-tauri/src/commands/paid_swaps.rs` — offer records and `find_payment_output`. +- `src-tauri/src/commands/names.rs::build_finalize_with_payment_draft` — W1. +- `src-tauri/src/noncustodial/actions.rs` — the `sighash::ALL` of W2. +- `name-modal-sections.test.tsx :: offers no way to start a paid swap` — pins + the withdrawal. diff --git a/docs/specs/README.md b/docs/specs/README.md index 24cbc268..c1257220 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,3 +21,4 @@ Each spec has these sections, in this order: | [2026-09-11 Remote-node connection & broadcast guard](./2026-09-11-remote-node-connection-and-broadcast-guard.md) | Implemented on `feat/spv-broadcast-guard-and-remote-node-onboarding` | | [2026-09-14 Network-derived behaviour](./2026-09-14-network-derived-behaviour.md) | Implemented on `feat/batch-transfer` | | [2026-09-20 Multiple bids per name](./2026-09-20-multiple-bids-per-name.md) | Implemented on `feat/batch-reveal-redeem-finalize-ui` | +| [2026-09-21 Paid name swaps](./2026-09-21-paid-name-swaps.md) | Not implemented — UI withdrawn, see spec | diff --git a/src/components/NameActionsModal.tsx b/src/components/NameActionsModal.tsx index b6fb5bad..b4b2f335 100644 --- a/src/components/NameActionsModal.tsx +++ b/src/components/NameActionsModal.tsx @@ -1086,24 +1086,6 @@ export function NameActionsModal({ 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 diff --git a/src/components/__tests__/name-modal-sections.test.tsx b/src/components/__tests__/name-modal-sections.test.tsx index d75e7685..4bcd14ca 100644 --- a/src/components/__tests__/name-modal-sections.test.tsx +++ b/src/components/__tests__/name-modal-sections.test.tsx @@ -303,6 +303,46 @@ describe("NameActionsModal — sections on a name the wallet really owns", () => expect(await screen.findByRole("button", { name: "Renew" })).toBeInTheDocument(); expect(await screen.findByText(/Sign message for/)).toBeInTheDocument(); }); + + // The paid-swap entry points are withdrawn. Only the holder of the TRANSFER + // coin can finalize, which is the sender, so "Buy with payment" could never + // be pressed by a buyer; and with every input signed SIGHASH_ALL nothing + // about the flow is atomic. See docs/specs/2026-09-21-paid-name-swaps.md. + // Claiming an offer already recorded is untouched — that panel renders + // itself only when one exists. + it("offers no way to start a paid swap", 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, + canFinalize: ok, + canRenew: ok, + }), + ), + ); + render( {}} />, { wrapper: wrapper() }); + await screen.findByTestId("name-phase"); + + expect(screen.queryByRole("button", { name: /sell with payment/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /buy with payment/i })).not.toBeInTheDocument(); + }); }); describe("NameActionsModal — the Register step", () => { diff --git a/src/components/name-actions/OwnershipActions.tsx b/src/components/name-actions/OwnershipActions.tsx index a4c9be01..12df604a 100644 --- a/src/components/name-actions/OwnershipActions.tsx +++ b/src/components/name-actions/OwnershipActions.tsx @@ -1,7 +1,5 @@ -import { useState } from "react"; import { Button } from "../ui/Button"; import { Input } from "../ui/Input"; -import { hnsToDollarydoos } from "../../lib/utils"; import { ActionHint } from "./ActionHint"; import type { NameActionCapabilities, NameActionCapability } from "../../types"; @@ -11,9 +9,11 @@ import type { NameActionCapabilities, NameActionCapability } from "../../types"; * F6 extraction from `NameActionsModal`). All state (recipient, busy) and * the mutation runner stay in the orchestrator and flow down as props. * - * `onBuyWithPayment` is the paid name swap flow: buyer finalizes a TRANSFER - * and pays the seller in the same transaction. Only shown when the name is - * in TRANSFER state (taskState === "transferPendingFinalize"). + * The paid-swap entry points were withdrawn: only the holder of the TRANSFER + * coin can finalize, which is the sender, so the "buyer" side could never be + * pressed by a buyer — and with every input signed SIGHASH_ALL nothing about + * the flow was atomic. See docs/specs/2026-09-21-paid-name-swaps.md. Claiming + * an offer already recorded still works, in `PaidSwapClaim`. */ export interface OwnershipActionsProps { caps: NameActionCapabilities | null | undefined; @@ -27,13 +27,6 @@ export interface OwnershipActionsProps { onCancelTransfer: () => void; onRenew: () => void; onRevoke: () => void; - onBuyWithPayment?: (paymentAddress: string, paymentValue: number) => void; - /** - * The paid-swap SELL flow: seller records an offer (buyer address + price) - * so they can later claim the payment once the buyer's finalize-with-payment - * tx confirms. Only shown when the name is owned (canTransfer). - */ - onSellWithPayment?: (buyerAddress: string, priceValue: number) => void; } export function OwnershipActions({ @@ -48,40 +41,9 @@ export function OwnershipActions({ onCancelTransfer, onRenew, onRevoke, - onBuyWithPayment, - onSellWithPayment, }: OwnershipActionsProps) { // Paid swap: show "Buy with payment" button + payment address input when // the name is in TRANSFER state (transferPendingFinalize). - const canFinalize = caps?.canFinalize; - const [showPayForm, setShowPayForm] = useState(false); - const [payAddr, setPayAddr] = useState(""); - const [payAmount, setPayAmount] = useState(""); - // Paid swap SELL: seller records an offer for a name they own. - const [showSellForm, setShowSellForm] = useState(false); - const [sellBuyerAddr, setSellBuyerAddr] = useState(""); - const [sellAmount, setSellAmount] = useState(""); - - const handleBuy = () => { - const amount = parseFloat(payAmount); - if (!payAddr.trim() || isNaN(amount) || amount <= 0) return; - // Convert HNS to dollarydoos via the shared helper (1 HNS = 1,000,000 doos). - const doos = hnsToDollarydoos(payAmount); - onBuyWithPayment?.(payAddr.trim(), doos); - setShowPayForm(false); - setPayAddr(""); - setPayAmount(""); - }; - - const handleSell = () => { - const amount = parseFloat(sellAmount); - if (!sellBuyerAddr.trim() || isNaN(amount) || amount <= 0) return; - const doos = hnsToDollarydoos(sellAmount); - onSellWithPayment?.(sellBuyerAddr.trim(), doos); - setShowSellForm(false); - setSellBuyerAddr(""); - setSellAmount(""); - }; return (
@@ -136,92 +98,7 @@ export function OwnershipActions({ {busy === "REVOKE" ? "…" : "Revoke"} - {canFinalize && !actionDisabled("FINALIZE", canFinalize) && onBuyWithPayment && ( - - )} - {showPayForm && ( -
- setPayAddr(e.target.value)} - placeholder="hs1q… / rs1q…" - /> - setPayAmount(e.target.value)} - placeholder="0.00" - type="number" - step="0.000001" - min="0" - /> -
- - -
-
- )} - {/* Sell with payment: seller records offer (buyer address + price) */} - {caps?.canTransfer && !actionDisabled("TRANSFER", caps?.canTransfer) && onSellWithPayment && ( - - )} - {showSellForm && ( -
- setSellBuyerAddr(e.target.value)} - placeholder="hs1q… / rs1q…" - /> - setSellAmount(e.target.value)} - placeholder="0.00" - type="number" - step="0.000001" - min="0" - /> -
- - -
-
- )}
); } From 68a844aaff327fcfe114f635eb2a736711eaf99c Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Mon, 21 Sep 2026 15:53:17 +0200 Subject: [PATCH 9/9] docs: changelog for the transfer lifecycle and the reclaimed-lockup balance --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf7a7c8..ba3c2ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ - **The name status no longer contradicts the modal it opens.** The Owned Names table printed the auction phase while the modal printed the task, so a row reading "Closed" opened a modal headed "Won — Register Now". The table now shows the same summary the auctions list and the modal do, and defers to what is in flight when a transaction of yours is waiting for a block. The phase was nearly a constant down that column anyway: every name you own has a closed auction. - **Reclaiming your own losing bids is no longer described as losing.** Outbidding yourself and winning leaves you owning the name and holding your own losing reveals — the ordinary outcome of bidding more than once. The wallet greeted it with a red "Lost — Redeem Now" on a name just registered. The state was right; only its description was written for the other way into it. - **Broadcasting your own transaction no longer reads as losing the name.** hsd drops a coin from its unspent set as soon as a mempool transaction spends it, so the instant a register or transfer went out the wallet stopped finding the owner coin and concluded it did not own the name — which, on a closed auction still holding reveals, is the shape of a loss. Ownership now survives an unconfirmed spend of your own; acting on the name stays blocked until the block lands, which was the half that was already right. +- **Finalize now waits out the transfer lockup instead of failing.** Handshake refuses a finalize until the transfer has been locked for a set number of blocks — two days on mainnet — and the button went live the moment the transfer was mined. It now says how many blocks are left, and only unlocks when the next block could actually carry it. +- **A pending transfer is now the name's status, not a phase that never arrives.** Handshake has no TRANSFER state: a name being transferred stays CLOSED and signals the transfer in a separate field, so the status was derived from a string the node never sends and every transferring name read "Owned" — while the panel below it said "Transfer in progress". The status now reads what the node actually reports, and sits behind the renewal alarm but ahead of everything quiet. +- **Renew no longer cancels a transfer, and a second transfer is no longer offered.** Handshake's renew clears a pending transfer exactly as an update does, so "extend my registration" ended a transfer in flight without mentioning transfers; it is refused while one is pending, and says why. Transfer itself was offered on a name already being transferred, which the node rejects outright — a transfer coin can only become an update, renew, finalize or revoke. +- **A reclaimed lockup counts as spendable money again.** Redeeming a losing bid returns ordinary HNS, but the coin it lands on was classified with the name covenants, so the balance card did not show it as spendable and coin selection would not draw on it. You paid a fee to get it back and it stayed invisible. It follows Handshake's own rule now — a redeemed coin spends like any other output. +- **The confirm dialog counts every output a name action carries.** It reported the first one, which is right for an action with a single output and wrong for the two that matter: revealing a name you bid on more than once, and redeeming the bids that lost. A redeem of three reveals worth 28 HNS offered 12 on the dialog — the one figure you check before signing. - **The paid-swap buttons are gone.** "Sell with payment" and "Buy with payment" offered a trade the code could not make. Finalizing a transfer spends the coin the TRANSFER created, and that coin stays at the seller's address — so only the seller can finalize, and the button labelled "Buy" could never be pressed by a buyer. Nor was anything atomic: every input is signed with a flag that forbids a counterparty from completing the transaction, so "pay and receive in one transaction" meant one wallet funding both halves of its own trade. Selling a name still works the ordinary way — agree a price, Transfer, Finalize — and an offer recorded before this change can still be claimed. What a real atomic swap needs is written down in `docs/specs/2026-09-21-paid-name-swaps.md`. - **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.