Skip to content

feat(shakedex): seller transactions — price steps, lock FINALIZE, cancel - #84

Open
DimazzzZ wants to merge 9 commits into
feat/shakedex-sell-T1afrom
feat/shakedex-sell-T1b
Open

DimazzzZ wants to merge 9 commits into
feat/shakedex-sell-T1afrom
feat/shakedex-sell-T1b

Conversation

@DimazzzZ

@DimazzzZ DimazzzZ commented Oct 8, 2026

Copy link
Copy Markdown
Owner

Stacked on #83; merge after it.

Second PR of the Shakedex selling stack (spec docs/specs/2026-10-05-shakedex-name-sales.md, ticket T1b): the seller's transactions — price-step signing, the FINALIZE into the lock, the cancel and its FINALIZE — each byte-identical with hsd 8.0.0, and the signer and Ledger rules that keep a lock key from signing anything else. No command uses them yet (that is T2–T5), so nothing is user-visible.

What changes

  • Price steps (R1). sell::sign_step signs the R3 template with 0x84 (ANYONECANPAY|SINGLEREVERSE) and verifies the signature before returning it; StepTemplate::sign is private to the module. sell::lock_self_check (R18 primitive) refuses a key whose script, program or address disagree, or whose probe signature does not verify. sell::buy_now_lock_time (R19) is the MTP minus 512 s, valid in the next block.
  • FINALIZE into the lock (R1). sell::build_lock_finalize_plan finalizes our TRANSFER coin to the P2WSH lock address, funded by our coins, fee on vsize.
  • Cancel and its FINALIZE (R1). cancel::build_cancel_plan: input 0 is the lock coin signed by the lock key with 0x83, witness [sig, lockScript]; output 0 a TRANSFER at the lock address committing to our reserved receive address. build_cancel_finalize_plan brings the name home (same shape as the buyer's FINALIZE).
  • Signing a lock coin (R17). PlanInput.lock_key_name (serde(default), old stored plans read and sign as before). The signer derives the lock key from the seed at sign time, never stores it, and signs a lock-key input only as a cancel: 0x83, final sequence, plan lock time 0, the same-index output a TRANSFER at that key's lock address with exactly 4 items, of this name, to a version-0 receive address of ours re-derived from the input's path (receive branch, unhardened index); an input that is also foreign is refused (shakedex::cancel::check_lock_key_input). A lock-key input is never reserved as our coin.
  • Ledger (R16). Draft signing refuses every shakedex_* action for a Ledger profile, because the FINALIZE into a lock is an ordinary plan the device could sign; the device signer refuses lock-key and foreign shapes. SECURITY.md states both exactly.
  • Funding (R4). Plan funding moved from purchase.rs into shakedex/funding.rs (shared by purchase, lock FINALIZE and cancel); a lock-key input is sized on its real witness; a cancel with no funding coins reports the fee shortfall instead of "need 0 HNS".

Checks

  • Unit: cargo fmt --check, cargo clippy --locked --all-targets -D warnings, cargo test --locked 2989 passed / 0 failed, pnpm test, prettier on tracked files — all pass.
  • hsd vectors: the generator builds the lock FINALIZE, three price steps (each filled and verified by hsd), the cancel and the cancel FINALIZE, and asserts verify() on each; our builders, signed through the real signer, match hsd's signed hex, txid and vsize-based fee. node gen_hsd_vectors.js regenerates vectors.json identically. An old plan JSON without the new field signs to the same hex as before the change.
  • Live (regtest, local only): full live_ suite with HNS_IT_SHAKEDEX=1 and the second node without --index-tx — 99 passed, 0 failed, 0 skipped (chain 11065 → 22130), on the final head. This PR adds no live test (it broadcasts nothing); the run checks every existing send still signs and lands.
  • Mutations (each made, the named test failed, file restored, git diff clean): 0x84→0x81 (step signing, self-check, step vectors); verify removed from sign_step; each self-check comparison and the probe; Buy Now lock time = MTP; FINALIZE paid to change or with a foreign witness; every refusal of the lock-key rule (sighash, missing output, not a TRANSFER, other lock, other name, foreign destination, off-path destination, version 1, short TRANSFER, change branch, hardened index, non-final sequence, lock time, also foreign); own_inputs exclusion; Ledger shape and action-class checks; lock witness sized as P2WPKH; cancel 0x83→0x81, witness order, TRANSFER to the wrong address, wrong path; the no-funding fee message; an action constant without the shakedex_ prefix.
  • Code review (standards and spec axes) done; findings fixed in this branch.

Not checked here: the commands that build these drafts and their live tests (T2–T5); R4's live fee pins name tests T3 and T5 add.

The lock FINALIZE and the cancel fund their transactions the same way a purchase does, so the fee sizer (R4) moves out of purchase.rs into funding.rs instead of being copied. The first input of fund is renamed from foreign to lead because it is now also our own name coin or our lock coin, and the overflow message says transaction instead of purchase. No behaviour change: the purchase vector tests and every shakedex test pass before and after. Gates run: fmt, clippy -D warnings, full cargo test.
Adds the seller's price-step signing (R1): `StepTemplate::sign` produces a 0x84 low-S signature, and `sell::sign_step` refuses to return one that does not verify against the template. The seller vectors (the FINALIZE into the lock, three price steps, the cancel and the cancel's FINALIZE) are generated by hsd and chained as on chain, all four shapes at once so `vectors.json` is regenerated once; only the new `shakedex.sell` key changed and a second run is byte-identical.

Adds the R18 day-0 self-check primitive (`sell::lock_self_check`: script, program, address and a test signature) and the R19 Buy Now lock time helper (`sell::buy_now_lock_time`, one lock-time unit below the MTP).

Mutations checked: sighash 0x84 to 0x81 fails the step vector and the sign test, dropping the verify fails the other-lock case and the "secret" self-check case, dropping each self-check comparison or the probe signature fails its case, and returning the MTP as the lock time fails the next-block test. fmt, clippy and the whole cargo test pass.
Adds sell::build_lock_finalize_plan: our name's TRANSFER coin is input 0 (P2WPKH, signed ALL) and our own coins fund it; output 0 is the FINALIZE paying the P2WSH lock address, then change. The generic finalize pays only P2WPKH addresses, so the lock needs its own builder.

R1: the signed transaction equals hsd's byte for byte (shakedex.sell.lockFinalize vector, verified by hsd's interpreter). R4: at a fee rate the fee is hsd's vsize times the rate, sized on the signed transaction through the shared funding module.

LOCK_FINALIZE_ACTION is reserved for the command in T3. Mutations: paying the change address instead of the lock, and giving input 0 a foreign witness, each fail lock_finalize_matches_hsd_signed_hex and lock_finalize_pays_the_lock_address_and_its_fee_on_vsize. fmt, clippy and the whole cargo test run are green.
A plan input can now be one of our lock coins: PlanInput gains lock_key_name (serde(default), so plans stored before it parse with None and sign to the same bytes as before, pinned by old_plan_json_signs_as_before). The lock key is never stored in the plan or the DB; sign_plan derives it at sign time from the unlocked session's master with derive_lock_key(network, plan.account, name) and writes the witness [signature, lock script].

R17 in the signer: a lock key signs only a cancel. The input must be sighash 0x83 (ANYONECANPAY|SINGLE), and output i must exist and be a TRANSFER at the key's lock address. Output i is checked because hsd's SINGLE past the last output hashes no output, so a 0x83 signature without it would let anyone spend the lock coin anywhere. The TRANSFER must also be of this name (item 0 is its name hash) and commit to an address of ours (item 2 version 0, item 3 the key hash the signer re-derives from the input's branch/child_index), because a TRANSFER to any other address would give the name away once finalized. The signer trusts no address the plan asserts. An input that is both foreign and a lock-key input is refused.

own_inputs leaves a lock-key input out: a lock coin is not a tracked coin, and insert_tx_draft_reserving_coins_in_tx refuses an outpoint it cannot claim. has_foreign_or_custom_inputs counts it, so the Ledger signer refuses such a plan before the device (R16). Every PlanInput literal gains lock_key_name: None. Spec R17 names the rule and its tests. cancel.rs holds CANCEL_SIGHASH; the builders follow.

Mutations, each caught: sighash check off (price step), TRANSFER type check dropped (not a TRANSFER), lock address check dropped (another lock), the output match replaced by Ok (another lock), the foreign-and-lock check removed (also foreign), witness order swapped (sign_plan_signs_a_lock_key_input_with_the_lock_key), name hash check dropped (another name), version check dropped (destination version 1), destination hash check dropped (a foreign destination), destination path ignoring the input's child_index (a destination off the input's path), item count check dropped (a TRANSFER without its address items panics), lock_key_name dropped from has_foreign_or_custom_inputs (own_inputs_leave_out_a_lock_key_input_and_the_ledger_refuses_it and the Ledger signer test), and from own_inputs (own_inputs_leave_out_a_lock_key_input_and_the_ledger_refuses_it).

Gates: cargo fmt, clippy --all-targets -D warnings, cargo test --locked (2976 + 3 passed), prettier on the spec.
The cancel destination path a lock-key input names was taken raw. A buggy builder or a tampered draft could commit the name to another branch, a hardened index or an index no scan reaches: derivable from the seed, but never synced or restored, so the name would be stranded. The signer now refuses a lock-key input unless its branch is the receive branch (the cancel commits to a reserved receive address, R21/R28) and its index is below the hardened offset. Checking that the address is a derived_addresses row of the profile's account stays with the command layer (T5); the signer has no DB access.

It also refuses a lock-key input with a non-final sequence or a plan with a lock time. The 0x83 signature commits to both, and a far lock time would make a cancel that cannot be mined while the price steps stay fillable.

refuse_unsupported_ledger_plan's doc names a lock coin of ours among what the Ledger cannot sign, and ledger_refuses_plan_with_lock_key_input pins it on a plan JSON. funding.rs::signed_vsize sizes a lock-key input on its real witness [signature, lock script] (script.rs gains LOCK_SCRIPT_LEN = 44) instead of a P2WPKH one, pinned by a_lock_key_input_is_sized_on_its_signed_witness (fee == signed vsize * rate). Spec R17 states the new conditions.

Mutations, each caught by the case named: receive-branch check dropped (a change-branch destination), unhardened-index check dropped (a hardened destination index), sequence check dropped (a non-final sequence), lock time check dropped (a lock time), a lock-key input sized as P2WPKH (a_lock_key_input_is_sized_on_its_signed_witness), lock_key_name dropped from has_foreign_or_custom_inputs (also ledger_refuses_plan_with_lock_key_input). The rules from the previous commit were re-run and are still caught.

Gates: cargo fmt, clippy --all-targets -D warnings, cargo test --locked (2978 + 3 passed), prettier on the spec.
R1: both hsd vectors reproduce byte for byte, the cancel signed by the seed-derived lock key through sign_plan. The cancel's lock input carries the cancel address's receive path (branch, index), which the signer re-derives and checks the TRANSFER against.

R4: fees equal hsd's vsize times the rate, with the real lock witness sized.

The cancel's FINALIZE is the same transaction as a buyer's FINALIZE out of the lock, so it reuses the buyer's FINALIZE builder. CANCEL_ACTION and CANCEL_FINALIZE_ACTION are reserved for the commands that persist these drafts.

Mutations checked: 0x83 to 0x81, swapped witness order, a P2WPKH-sized lock input, the TRANSFER paid to the cancel address, and the path dropped from the lock input each fail a vector or unit test.
R16/R29: a Shakedex draft is refused for a Ledger by its action as a class, because the FINALIZE into a lock is an ordinary plan the device could sign and a list of known actions would miss the next one. The cancel and its FINALIZE are also refused by their shape, since the signer's own guard sees no action.

SECURITY.md states the lock key's full signing rule as committed in refuse_unless_cancel, and spec R16 names the new pinned test. Also small follow-ups from earlier steps: the cancel FINALIZE vector test checks the txid, the cancel fee test doc names the hsd vsize pin, StepTemplate::sign is pub(super), the lock self-check keeps the probe's error, and the two input structs have a doc line.
…nd tidy funding

The lock-key cancel check moves from the generic signer to shakedex/cancel.rs as check_lock_key_input, the fee floor moves to funding.rs, fund() reads the lead's value from the lead itself, and a fund with no coins reports the network fee shortfall. The Ledger class guard gains negatives, a software-profile check and a scan that every action constant carries the prefix.
@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. labels Oct 8, 2026
@DimazzzZ
DimazzzZ added this pull request to stack #85 October 8, 2026 20:52
@DimazzzZ DimazzzZ self-assigned this Oct 8, 2026
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: wallet Balances, send/receive, accounts, HD derivation, coin selection.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant