Skip to content

fix(names): finish the transfer lifecycle, and withdraw the paid-swap buttons - #62

Merged
DimazzzZ merged 9 commits into
mainfrom
stack/g-transfer-lifecycle-and-paid-swaps
Sep 21, 2026
Merged

DimazzzZ merged 9 commits into
mainfrom
stack/g-transfer-lifecycle-and-paid-swaps

Conversation

@DimazzzZ

Copy link
Copy Markdown
Owner

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 + transferLockup blocks 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 is SIGHASH_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, and docs/specs/2026-09-21-paid-name-swaps.md` records what a real implementation needs.

Rust 2566, frontend 890.

@github-actions github-actions Bot added domain: wallet Balances, send/receive, accounts, HD derivation, coin selection. domain: names Auction lifecycle, batch ops, watchlist, paid swaps, lost-bid recovery. domain: sync Background sync daemon, auto-sync, cross-process DB locks. labels Sep 21, 2026
@DimazzzZ DimazzzZ added type: bug Defect or unexpected behavior. domain: ui UI/UX: components, dialogs, tables, onboarding, updates. priority: high Impacts users; should ship soon. labels Sep 21, 2026
@DimazzzZ
DimazzzZ added this pull request to stack #63 September 21, 2026 15:16
@DimazzzZ
DimazzzZ force-pushed the stack/g-transfer-lifecycle-and-paid-swaps branch from 93e99ab to 477507e Compare September 21, 2026 16:32
@github-actions github-actions Bot removed the domain: ui UI/UX: components, dialogs, tables, onboarding, updates. label Sep 21, 2026
@DimazzzZ
DimazzzZ force-pushed the stack/g-transfer-lifecycle-and-paid-swaps branch 6 times, most recently from ff9f33a to 6966335 Compare September 21, 2026 17:06
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
DimazzzZ force-pushed the stack/g-transfer-lifecycle-and-paid-swaps branch from 6966335 to 68a844a Compare September 21, 2026 17:10
@DimazzzZ DimazzzZ self-assigned this Sep 21, 2026
@DimazzzZ
DimazzzZ merged commit 241a6d8 into main Sep 21, 2026
7 checks passed
@DimazzzZ
DimazzzZ deleted the stack/g-transfer-lifecycle-and-paid-swaps branch September 21, 2026 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: names Auction lifecycle, batch ops, watchlist, paid swaps, lost-bid recovery. domain: sync Background sync daemon, auto-sync, cross-process DB locks. domain: wallet Balances, send/receive, accounts, HD derivation, coin selection. priority: high Impacts users; should ship soon. type: bug Defect or unexpected behavior.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant