diff --git a/CHANGELOG.md b/CHANGELOG.md index 2178f31c..ba3c2ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ - **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. - **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-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 338bf584..a79b5889 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, @@ -478,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). @@ -671,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" @@ -698,6 +723,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(), @@ -706,6 +739,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, @@ -755,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 @@ -797,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) } @@ -812,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. @@ -866,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, @@ -922,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, @@ -1100,25 +1155,51 @@ 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 }, }; + // 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" } + ) + }) }, }; @@ -1138,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 }, @@ -1229,6 +1316,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, @@ -1436,6 +1524,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, @@ -1504,6 +1597,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 @@ -1538,7 +1637,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 { @@ -3508,6 +3608,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, @@ -3541,6 +3643,7 @@ mod tests { owner_covenant_type, days_until_expire, has_pending_open, + false, reveal_txid, reveal_draft_status, Network::Main, @@ -3969,19 +4072,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 ); @@ -4586,6 +4695,144 @@ 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 + /// 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. @@ -4742,6 +4989,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( @@ -4818,6 +5067,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( @@ -4847,6 +5098,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( @@ -5077,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 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/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(); 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/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-tauri/src/tests/name_capabilities_tests.rs b/src-tauri/src/tests/name_capabilities_tests.rs index 6b27e19a..da9e3912 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, @@ -71,6 +73,7 @@ fn task_state_available_no_pending_open() { None, None, false, + false, None, None, Network::Main, @@ -90,6 +93,7 @@ fn task_state_available_with_pending_open() { None, None, true, // has_pending_open + false, None, None, Network::Main, @@ -109,6 +113,7 @@ fn task_state_empty_phase_treated_as_available() { None, None, false, + false, None, None, Network::Main, @@ -128,6 +133,7 @@ fn task_state_opening_phase() { None, None, false, + false, None, None, Network::Main, @@ -147,6 +153,7 @@ fn task_state_bidding_with_commitment() { None, None, false, + false, None, None, Network::Main, @@ -168,6 +175,7 @@ fn task_state_bidding_without_commitment() { None, None, false, + false, None, None, Network::Main, @@ -187,6 +195,7 @@ fn task_state_reveal_no_commitment_returns_unavailable() { None, None, false, + false, None, None, Network::Main, @@ -206,6 +215,7 @@ fn task_state_reveal_with_broadcasted_draft() { None, None, false, + false, None, Some("broadcasted"), Network::Main, @@ -225,6 +235,7 @@ fn task_state_reveal_with_broadcast_pending_draft() { None, None, false, + false, None, Some("broadcast_pending"), Network::Main, @@ -244,6 +255,7 @@ fn task_state_reveal_with_confirmed_draft() { None, None, false, + false, None, Some("confirmed"), Network::Main, @@ -264,6 +276,7 @@ fn task_state_reveal_with_dropped_draft_and_unspent_bid_coin() { None, None, false, + false, None, Some("dropped"), Network::Main, @@ -284,6 +297,7 @@ fn task_state_reveal_with_txid_and_spent_bid_coin() { None, None, false, + false, Some("abc123"), None, Network::Main, @@ -303,6 +317,7 @@ fn task_state_closed_owns_name_unregistered() { Some(2), // COV_OPEN < COV_REGISTER None, false, + false, None, None, Network::Main, @@ -322,6 +337,7 @@ fn task_state_closed_owns_name_already_registered() { Some(6), // COV_REGISTER None, false, + false, None, None, Network::Main, @@ -341,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, @@ -361,6 +378,7 @@ fn task_state_closed_owns_name_no_coin_synced() { None, None, false, + false, None, None, Network::Main, @@ -380,6 +398,7 @@ fn task_state_closed_lost_has_reveal_coin() { None, None, false, + false, None, None, Network::Main, @@ -388,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, @@ -418,6 +440,7 @@ fn task_state_revoked_phase() { None, None, false, + false, None, None, Network::Main, @@ -437,6 +460,7 @@ fn task_state_unknown_phase_owned() { None, None, false, + false, None, None, Network::Main, @@ -456,6 +480,7 @@ fn task_state_unknown_phase_not_owned() { None, None, false, + false, None, None, Network::Main, @@ -756,6 +781,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 +810,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 +844,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, ) 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() { 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; 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/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__/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/__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(); + }); }); 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" - /> -
- - -
-
- )}
); }