Skip to content

feat(shakedex): lock a name for sale, and keep a listed name from being spent - #86

Open
DimazzzZ wants to merge 19 commits into
feat/shakedex-sell-T1bfrom
feat/shakedex-sell-T2
Open

DimazzzZ wants to merge 19 commits into
feat/shakedex-sell-T1bfrom
feat/shakedex-sell-T2

Conversation

@DimazzzZ

@DimazzzZ DimazzzZ commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #84; merge after it.

Third PR of the Shakedex selling stack (spec docs/specs/2026-10-05-shakedex-name-sales.md, ticket T2): locking a name for sale on day 0, the gates that keep a locking or locked name from being spent by anything else, and the chain-derived end of a lock that never reaches the market. The UI comes in T7; this PR adds the backend command and its rules.

What changes

  • Lock a name (R18, R19, R21, R29, R31). shakedex_build_lock_draft builds the day-0 TRANSFER of an owned name to its lock address (derived from the seed, R17), after lock_self_check passes. It refuses without the unlocked signer, without write capability, for Ledger / watch-only / extended-private-key profiles, on mainnet without the experimental flag, when publishing is asked off mainnet, and when the name would expire before the seller's FINALIZE into the lock (R31: expiry_end <= tip + 1 + remaining lockup + 1 day, judged on hsd's own name state and tip, fail closed on a missing field). Below 180 such days it warns, saying the FINALIZE into the lock renews the name. The draft, the listing row (shakedex_listings, migration 034) and the reserved payment and receive-branch cancel addresses are written in one transaction. R31 is judged again when the lock is sent; a refusal deletes the unsent draft and its listing.
  • No owner action on a listed name (R27). While a listing blocks the name, Update, Renew, Transfer, Finalize, Revoke and the batch actions are not offered and their builders refuse ("this name is locked for sale through Shakedex"). Cancel transfer stays the abort while the listing is Locking or ReadyToFinalize. A Locking listing whose lock TRANSFER draft is dropped, failed or deleted does not freeze the name. The lock and lock-FINALIZE drafts count as owner spends in flight.
  • The end of a lock before the market (R19). A read-only sync step (app and daemon, no sends) resolves a Locking listing from chain facts: Expired when hsd has no live name; Aborted when the name's owner is no longer our lock TRANSFER (or the name is revoked), the lock TRANSFER is neither in the chain nor in the mempool, and its draft cannot still land — which covers a Cancel transfer from this or another device, a revoke, and a lock TRANSFER that never landed; back to Locking on a reorg.
  • Existing behaviour fixed on the way. A Cancel transfer in flight now counts as an owner spend (the check compared "cancel_transfer", but the draft is saved as "cancel"), so other owner actions wait for it. Renew Selected is disabled unless every selected name can be renewed now (the batch otherwise failed, or silently cancelled a pending transfer). Both are in CHANGELOG.md.
  • Refactors. One gate for the unlocked signer (SignerSession::authorize); draft persistence shared with the lock builder (draft_ctx); R9's and R31's day margins from one NameParams helper.

Checks

  • Unit: cargo fmt --check, cargo clippy --locked --all-targets -D warnings, cargo test --locked 3057 passed / 0 failed, pnpm test 991 passed, prettier on tracked files — all pass.
  • Live (regtest, local only): full live_ suite with HNS_IT_SHAKEDEX=1 and the second node without --index-tx — 103 passed, 0 failed, 0 skipped (chain 0 → 11443). New: shakedex_lock_transfer_commits_to_the_lock_address (the mined TRANSFER's covenant commits to the lock program at the name's height; the name stays at our address), shakedex_cancel_transfer_aborts_the_listing_on_chain (Locking while the cancel is unmined, Aborted only after the job reads it mined), shakedex_lock_refused_near_expiry (both sides of the R31 boundary against hsd's renewalPeriodEnd, the chain rewound with invalidateblock even on panic).
  • hsd vector: the day-0 TRANSFER covenant matches hsd's encoding byte for byte.
  • Migration: 034 applies on top of 033 (test). The local app database checked read-only is at an older schema, so no real 033 → 034 upgrade was exercised.
  • Mutations (each made, the named test failed, file restored, git diff clean): every lock refusal (signer, capability, profile kinds, flag, publish, R31 refuse/warn boundaries on mainnet and regtest, self-check, pending transfer, open listing, name not ours, missing tip or name fields); every owner-action gate and builder refusal including Revoke; the cancel-abort window; the dead-lock-draft rule (each live status); the owner-spend actions; the "cancel" spelling; draft deletion keeping a listing for a broadcast draft and only its own; abort link txid and the vout guard; each chain-resolution branch (external cancel, revoke, dead lock, replaced cancel, broadcasted-but-404, info null, each missing field); the R31 re-check at send; the three live tests.
  • Code review (standards and spec axes) done; findings fixed in this branch.

Not checked here: Finalize & sign, Sold, Cancel out of the lock, publishing and the UI (T3–T7). The spec's Pinned lines for those name tests the later PRs add.

The gate (unlocked, same profile, touch) and the session lifetime were each written twice; the lock command (T2) needs both and may not import commands::tx. No behaviour change; gates run.
Migration 034 adds shakedex_listings, with a CHECK listing exactly the ListingState spellings and unique indexes for one open listing per name and one listing per lock outpoint. An unsent lock draft that is deleted takes its Locking listing with it.
…sent

Deleting a lock draft removed its Locking listing for every deletable status, including dropped and failed, which are given to drafts that were broadcast and may still be mined. Only a draft that was never sent (draft, signed) now takes its listing with it. Also pin that another draft's deletion leaves the listing alone, and document the unit of expires_at.
The day-0 TRANSFER's covenant is checked against the bytes hsd encodes for the coin the lock FINALIZE vector spends. Adds the lock action, the lock summary and the sentences the lock command and the owner-action gates use.
R31: a name whose expiry end is at or below tip + 1 + the lockup still to run + a day is refused, and one that expires less than 180 days after the tip is allowed with a warning. The margin is relative to the remaining lockup, so Finalize & sign can pass what is left of it.

R9's day and margin now live once in NameParams and are used by both the buyer's finalize_deadline_ok and the seller's guard.
shakedex_build_lock_draft builds day 0 of a listing (R18, R19, R21, R29, R31): the TRANSFER that commits our owner coin to the SHA3-256 of the lock script of the key derived from the seed for this name and account, staying at our own address until finalized, and the Locking listing that follows it. The draft, the reserved payment and cancel addresses and the listing are written in one database transaction, so a lock TRANSFER never exists without its listing.

Gates, in order: a recovery-phrase profile on a node that can send (Ledger, watch-only and extended-private-key profiles refused with the UI's sentence), the mainnet experimental flag for a new listing (now one helper shared with new purchases), publishing to LearnHNS only on mainnet, the unlocked signer of this profile (the lock key is derived only to read its public key and run the R18 self-check, and is not kept), our owner coin of the name, then R31 on one getnameinfo reply and the node's reported tip: a reply without the tip, the renewal height or the claimed count is "could not check". The builder then refuses a name whose expiry is inside the lockup margin, a pending transfer, a coin hsd would not let go to a TRANSFER, a name already locked, and a key that fails its self-check, all before anything is written. The near-expiry warning is added only for a Warn verdict; what locking costs is always on the draft, which reads back as a plain TxSummary plus its name.

reserve_receive_address now returns the reserved address with its branch and index, so the cancel address's receive-branch index is stored on the listing (T5 puts it on the cancel's lock input). name_input_from moves to draft_ctx (names.rs re-exports it), and draft_ctx gains name_state_strict for a reply already in hand. Step 4's temporary dead_code allowances are gone, and the finalize_margin, ExpiryNotice::Refuse and no-state wording follow the review.

SECURITY.md gains the lock-day key derivation and a threat row for a name locked to a key that cannot move it; the spec's R17 and R21 Enforced lines name the new code. Gates run: fmt, clippy -D warnings, the whole cargo test, prettier; 19 mutations each caught by a named test.
R27: while a name has an open listing, Update, Renew, Transfer and Finalize are not offered (reason "this name is locked for sale through Shakedex", which wins over the spend lock, since a listed name has no owner coin of ours) and their builders refuse it before any node call: single, batch and Finalize with payment. Cancel transfer stays offered and built while the listing is Locking or ReadyToFinalize, where it is the abort (R19), and is refused from Finalizing on. The lock TRANSFER and the FINALIZE into the lock join owner_spend_in_flight. Renew Selected in WalletView is now offered only when every selected name may be renewed; it had no capability gate before.

Every gate and builder refusal was mutation-checked against names_cmd_tests::locked_name_offers_no_owner_actions, names_action_context_tests and the batch renew UI test.
A REVOKE of the lock TRANSFER is valid in hsd and would burn a name being sold, so Revoke joins the R27 gate and refuse_while_listed. A Locking listing now keeps the owner actions away only while its lock TRANSFER draft is alive (draft, signed, broadcast_pending, broadcasted, confirmed): a dropped, failed or deleted lock draft no longer freezes the name until the chain refresh resolves it. Listings past Locking block as before. The capability gate and the builders read the same query, db::queries::listing_blocking_owner_actions.
R19: between day 0 and the FINALIZE into the lock, the name's existing Cancel transfer is the listing's abort. build_cancel_draft now links its draft to the listing whose lock TRANSFER (output 0) the cancel spends, in the same database transaction as the draft (persist_with_conn is split into persist_in_tx for that). Deleting an unsent cancel clears the link.

The verdict rests on a found fact, never on the broadcast: hsd answers sendrawtransaction with a txid even when its mempool refuses. A new sync step, shakedex_jobs::refresh_listing_aborts_with_client, reads hsd's GET /coin (no transaction index needed): the cancel's UPDATE mined in a block makes the listing Aborted; the UPDATE back in the mempool, or the lock TRANSFER a coin again, makes it Locking again after a reorg. A coin without hsd's height, both coins gone, or no answer leave the listing as it is. Aborted listings are re-checked for 7 days.

The step runs from run_sync_steps against an authoritative node, so the app and namehold-syncd both run it; it only reads the node and sends nothing. A reorg revert that would collide with a newer open listing of the same name (idx_shakedex_listings_open_name) is refused by the query's own guard: the old listing stays Aborted and the newer one stays open.

Deviation from the step plan: the plan applied the abort from the draft's confirmed status inside refresh_tx_confirmations, which only the app runs; the daemon never confirms drafts. The verdict is therefore derived from the chain in a sync step, like the purchase refresh.

Gates: cargo fmt, cargo clippy --all-targets -D warnings, cargo test --locked (3038 + 3 passed). Mutation-checked: 11 mutations, each caught by a named test.
Review follow-ups for the Cancel transfer abort (R19):

- delete_tx_draft_in_tx cleared a listing's abort link for any deletable cancel, including a dropped or failed one. Those were broadcast and may still be mined, and with the link gone the abort job could never mark the listing Aborted. The unlink now runs only for a draft never sent (draft, signed), the same gate the lock-draft branch uses. deleting_an_unsent_cancel_unlinks_it covers draft and signed (link cleared) and dropped and failed (link kept).
- When a reorg would revert an Aborted listing but another listing of the name is open, the job now logs that the old listing stays Aborted and why, instead of saying nothing.

Gates: cargo fmt, cargo clippy --all-targets -D warnings, cargo test --locked (3038 + 3 passed). Mutation-checked: an unconditional unlink fails the test at dropped; no unlink fails it at draft.
Round 1 kept a dropped or failed cancel's abort link when its draft was deleted, but the abort job looked the cancel's txid up in that draft and returned early when the draft was gone. A cancel mined after its draft was deleted could therefore never abort the listing.

The listing now stores the cancel's txid in a new column, abort_txid (migration 034, unreleased on this branch, edited in place). build_cancel_draft writes it with the link. Its value is the plan's txid, i.e. the no-witness hash of the unsigned tx the draft stores, which is what the job used to decode. Deleting a draft or signed cancel clears both columns; a dropped or failed one keeps both. The job and the abort/unabort queries now key on abort_txid and no longer read the draft.

Test: deleted_dropped_cancel_mined_later_still_aborts. The existing tests also pin that the txid is stored at link time and is cleared or kept on delete. Mutation-checked: the job reading the txid from the draft again, the unlink keeping abort_txid, and the link not storing it each fail a named test.

Gates: cargo fmt, cargo clippy --all-targets -D warnings, cargo test --locked (3039 + 3 passed, migration_034_applies_on_top_of_033 included).
…ith the lock

The lock draft is written by the same persist_in_tx as every name draft (moved with draft_summary and ActionSummary into commands/draft_ctx.rs, which now carries the draft's warnings and reserves only the plan's own inputs), so LockSummary is gone. The unsent, holding and may-still-land draft statuses are derived from one pair of lists, and the SQL that picks the listings a Cancel transfer aborts is generated from ListingState::CANCEL_ABORTABLE. The lock key is derived after the database and node reads, just before the draft is built; the signer is still authorized first.
A Locking listing was Aborted only by the Cancel transfer this wallet linked to it, so a cancel sent from another device, a REVOKE, a replaced cancel whose older one was mined, or a lock TRANSFER that never landed left it Locking for good, and the name could not be locked again. The sync step, renamed refresh_listings_before_lock_*, keeps the linked-cancel evidence and then reads hsd's getnameinfo and GET /coin: info null makes the listing Expired; the lock TRANSFER neither the owner of an unrevoked name nor a coin, while its draft can no longer land, makes it Aborted; an Aborted listing whose lock TRANSFER is a coin or the owner again is Locking again. A read error or a missing field changes nothing. Spec R19 updated.
R31 was judged only when the lock draft was built, but the draft may be sent days later, when the name would expire during the transfer lockup. broadcast_tx_draft now re-runs lock_expiry_guard with the full transfer lockup from the node's tip for a shakedex_lock draft before sending it, and refuses with the build's own sentence; the unsent draft is discarded with its Locking listing, so the name is free to renew. A node that cannot say leaves both and sends nothing. The guard moves to noncustodial/shakedex/sell.rs and the re-check lives in commands/draft_ctx.rs, so commands/tx.rs does not reach into commands/shakedex.rs. Spec R31 updated.
…hain on a panic

The three T2 live tests read HNS_IT_SHAKEDEX through shakedex_node_env, the node half of shakedex_env: unset skips, anything but 1 fails. The near-expiry test holds a RewindOnDrop guard that takes the mined stretch back out with its own client and runtime if the test panics before its own rewind. The lock TRANSFER test also asserts covenant items[1] equals the name's height from getnameinfo. REGTEST_TESTING.md says so.
@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: node hsd full-node integration (RPC, chain scan) and SPV mode. domain: sync Background sync daemon, auto-sync, cross-process DB locks. domain: security Secure window, encrypted vault, signing engine, secret handling. domain: infra CI/CD, build, sidecar packaging, updater, DB migrations, deps. labels Oct 9, 2026
@DimazzzZ DimazzzZ self-assigned this Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain: infra CI/CD, build, sidecar packaging, updater, DB migrations, deps. domain: names Auction lifecycle, batch ops, watchlist, paid swaps, lost-bid recovery. domain: node hsd full-node integration (RPC, chain scan) and SPV mode. domain: security Secure window, encrypted vault, signing engine, secret handling. domain: sync Background sync daemon, auto-sync, cross-process DB locks. domain: wallet Balances, send/receive, accounts, HD derivation, coin selection.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant