Skip to content

feat(shakedex): lock keys from the seed, P2WSH signing and reserved addresses - #83

Open
DimazzzZ wants to merge 6 commits into
mainfrom
feat/shakedex-sell-T1a
Open

DimazzzZ wants to merge 6 commits into
mainfrom
feat/shakedex-sell-T1a

Conversation

@DimazzzZ

@DimazzzZ DimazzzZ commented Oct 8, 2026

Copy link
Copy Markdown
Owner

First PR of the Shakedex selling stack (spec docs/specs/2026-10-05-shakedex-name-sales.md, ticket T1a): lock keys from the seed, a P2WSH signing primitive, and reserved receive addresses. Nothing here is user-visible yet. Base: main.

What changes

  • R17 — lock keys from the seed. noncustodial/shakedex/lock_key.rs: lock_key_index (SHA3-256 of the name, first four bytes big-endian, masked to 31 bits), lock_key_path(network, account, name) = m/44'/<coin>'/<account>'/2'/<i>', every level hardened, and derive_lock_key returning the key, its lock script, program and address. Pinned by R17's golden table and by hsd-generated lockPath vectors, including a name whose hash has the top bit set so the mask is exercised.
  • R17 — coins at a lock address are not counted. Coin selection already joined derived_addresses; compute_balances now does too, so a coin someone sends to a lock address is neither spendable nor counted in the balance. Production only ever records coins at the profile's own addresses, so existing balances do not change; 19 test fixtures that put coins at addresses the profile did not own now own them.
  • R1 — P2WSH signing primitive. Transaction::sign_p2wsh_input signs with the whole witness script as the script code (matches hsd for scripts without OP_CODESEPARATOR, as the lock script is) and returns the signature; P2WPKH signing shares the same sign_sighash helper, byte-for-byte unchanged. Pinned against a P2WSH spend signed and verified by hsd 8.0.0.
  • R21 — reserved receive addresses. derivation::reserve_receive_address allocates the next receive address and marks it used under a SAVEPOINT (nests inside a caller's transaction); ADDRESS_USED_PREDICATE now honours derived_addresses.used, so a reserved address is never reissued.
  • Spec: R17/R21 Pinned names updated; R21 and §5 now say a reserved address is found after a restore only within the restore window of address_gap_limit addresses (the window is never extended — a pre-existing gap, tracked separately).

Checks

  • Unit: cargo fmt --check, cargo clippy --locked --all-targets -D warnings, cargo test --locked 2969 passed / 0 failed, pnpm test, 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 — 99 passed, 0 failed, 0 skipped (chain 11065 → 22130). No live test is new in this PR; the run checks nothing regressed.
  • Vectors: node gen_hsd_vectors.js regenerates vectors.json identically.
  • Mutations (each made, the named test failed, file restored, git diff clean): hardened bit dropped on the lock branch → lock_key_matches_the_r17_golden_vector, every_level_is_hardened, lock_key_is_not_derivable_from_the_account_xpub fail; 31-bit mask dropped → golden vector fails; account ≥ 2^31 refusal removed → every_level_is_hardened fails; raw SHA3 instead of names::hash_name → lock_key_of_an_invalid_name_is_refused fails; JOIN → LEFT JOIN in load_spendable_coins → coins_at_a_lock_address_are_not_spendable fails; JOIN removed from compute_balances → same test fails on the balance; P2WPKH script code in sign_p2wsh_input → sign_p2wsh_input_matches_hsd fails; sighash byte zeroed in sign_sighash → P2WSH and four P2WPKH parity vectors fail; d.used = 1 or the mark_address_used call removed → reserved_addresses_are_not_reissued fails; SAVEPOINT replaced by a transaction → the nested case fails.
  • Code review (standards and spec axes) done; findings fixed in this branch.

Not checked: the rollback path of reserve_receive_address has no test.

R17 / ADR 0004: the lock key of a name sits at m/44'/<coin>'/<account>'/2'/<i>' with every level hardened, where i is the first four bytes of the name hash masked to 31 bits, so no account xpub can compute it.

lock_key_index and lock_key_path return a Result: they hash through names::hash_name, which refuses a name that can never be on chain, and lock_key_path refuses an account at or above 2^31 instead of clamping it.

The hsd vector gains a second name, namehold: the 0x7fffffff mask is invisible on dexreviews (its hash has the top bit clear), so only a name with the top bit set can pin it. The generator also emits nameHashPrefix, index and lockPub. R17's Pinned line gains lock_key_of_an_invalid_name_is_refused.

Gates: shakedex_lock_key_tests, hsd_parity, shakedex_vector_tests, full cargo test, clippy, prettier.
Pins an existing rule (R17: coins someone else sends to a lock address are not counted as spendable) through the two mechanisms that hold it today. Coin selection INNER JOINs tracked_utxos to derived_addresses, so a coin at an address that is not a derived address is never offered, even if it were recorded as liquid HNS. Node sync only asks the node for derived addresses, and derived_addresses.branch has CHECK (branch IN (0, 1)), so a lock address can never become one.

No production change. Mutation check: turning the JOIN in load_spendable_coins into a LEFT JOIN with COALESCE(d.branch, 0), COALESCE(d.child_index, 0) makes the test fail on the coin count (2 instead of 1). The sync assertion documents the other mechanism and has no one-line mutation, so it is not mutation-checked.
Adds `Transaction::sign_p2wsh_input`, which signs an input as a spend of a witness script and returns the 65-byte witness signature. It leaves the witness alone: the stack differs per spend (a price step's purchase carries `[signature, script]`, a FINALIZE out of a lock carries `[script]`), so the caller sets it.

The signing core is now a private `sign_sighash` shared with `sign_p2wpkh_input`; every hsd-parity signed hex stays identical.

The new `p2wshSpend` vector is generated by hsd 8.0.0 (fixed key 0x11..., script `<pub> OP_CHECKSIG`, SIGHASH_ALL, verified by hsd's interpreter). `sign_p2wsh_input_matches_hsd` pins sighash, signature, signed hex and txid. Regenerate with `cd src-tauri/tests/vectors && node gen_hsd_vectors.js`; only the new key is added to `vectors.json`.

Mutations that fail the test: using the P2WPKH script code instead of the script, and zeroing the sighash type byte (the latter also fails four hsd-parity vectors through the shared core).

Gates: cargo fmt, clippy -D warnings, full cargo test, prettier.
R21: `reserve_receive_address` allocates the next unused receive address of a profile and marks it used at once, so the next allocation returns another one. Listing payment and cancel destinations will be reserved this way, because nothing reaches them until a buyer pays or a cancel is mined.

The shared `ADDRESS_USED_PREDICATE` now reads `derived_addresses.used`. Without it, marking an address used had no effect on allocation or on the receive-address list, which only looked at tracked UTXOs, bid commitments and Shakedex purchase destinations. `used = 1` is set by node sync only for addresses that also get a `tracked_utxos` row, so existing wallets are unaffected.

The reservation runs under a SAVEPOINT instead of its own BEGIN: it is atomic on its own and still works inside a caller's transaction. It takes no `account` parameter: the account, network and xpub come from the profile row, so a caller cannot persist addresses under an account that disagrees with the stored xpub.

Gates: cargo fmt, clippy -D warnings, full cargo test --locked.
A restore provisions a fixed window of address_gap_limit addresses and does not extend it, so a reserved address is found after a restore only while its index is inside that window. The doc of reserve_receive_address and spec R21 claimed the gap-limit scan always reaches it; the matching test assertion held whatever the reservation did and is removed. Window extension is recorded as a known gap in the spec.

The SAVEPOINT cleanup now runs ROLLBACK TO and RELEASE as separate statements so RELEASE always runs, and logs a rollback error instead of dropping it. The mark_address_used doc notes its use for reservations.
compute_balances summed every tracked UTXO, so a coin at a Shakedex lock address showed as spendable although coin selection refuses it. It now joins derived_addresses as load_spendable_coins does.

Also: lock_key_path takes a Network instead of a raw coin type, the golden-vector test is renamed to state its behaviour, the address-used docs list everything ADDRESS_USED_PREDICATE checks, and the two copies of the in-memory profile fixture are one shared helper.
@github-actions github-actions Bot added domain: wallet Balances, send/receive, accounts, HD derivation, coin selection. domain: sync Background sync daemon, auto-sync, cross-process DB locks. labels Oct 8, 2026
@DimazzzZ DimazzzZ self-assigned this Oct 8, 2026
@DimazzzZ
DimazzzZ added this pull request to stack #85 October 8, 2026 20:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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