fix(names): finish the transfer lifecycle, and withdraw the paid-swap buttons - #62
Merged
Merged
Conversation
DimazzzZ
added this pull request to stack #63
September 21, 2026 15:16
DimazzzZ
force-pushed
the
stack/g-transfer-lifecycle-and-paid-swaps
branch
from
September 21, 2026 16:32
93e99ab to
477507e
Compare
DimazzzZ
force-pushed
the
stack/g-transfer-lifecycle-and-paid-swaps
branch
6 times, most recently
from
September 21, 2026 17:06
ff9f33a to
6966335
Compare
Base automatically changed from
stack/f-ownership-requires-registration
to
main
September 21, 2026 17:10
…at is in flight 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.
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.
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.
…he live tip 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.
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.
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.
…catch 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.
…needs 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.
DimazzzZ
force-pushed
the
stack/g-transfer-lifecycle-and-paid-swaps
branch
from
September 21, 2026 17:10
6966335 to
68a844a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stack 7/7, on top of #61. Found by driving a transfer end to end on regtest.
Finalize was offered through the whole lockup. Handshake refuses it until
transfer + transferLockupblocks have passed — two days on mainnet — and the button went live the moment the transfer was mined, sending the user at a transaction the node throws away. It now counts the blocks remaining instead of saying no.A pending transfer was not the name`'s status. Handshake has six name states and TRANSFER is not one of them: a name being transferred stays CLOSED and signals it in a separate field. So the task state keyed on a string the node never sends, and every transferring name read "Owned" while the panel below it said "Transfer in progress".
Renew silently cancelled a transfer. Handshake`'s renew clears a pending transfer exactly as an update does, so "extend my registration" ended a transfer in flight without mentioning transfers. Update was gated for this in an earlier branch; its twin was not. Transfer itself was offered on a name already being transferred, which the node rejects outright.
A reclaimed lockup did not count as money. Redeeming a losing bid returns ordinary HNS, but the coin 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.
The confirm dialog under-reported the amount. It showed the first output, which is right for a single-output action 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 paid-swap buttons are withdrawn. "Sell with payment" and "Buy with payment" offered a trade the code cannot make: finalizing spends the coin the TRANSFER created, and that coin stays at the seller
's address, so only the seller can finalize and the "Buy" button could never be pressed by a buyer. Nor was anything atomic — every input isSIGHASH_ALL, which leaves no room for a counterparty to complete the transaction. Selling still works the ordinary way, an offer already recorded can still be claimed, anddocs/specs/2026-09-21-paid-name-swaps.md` records what a real implementation needs.Rust 2566, frontend 890.