Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6f0e277
feat(shakedex): store a listing's sale, its way back after a reorg, a…
DimazzzZ Oct 9, 2026
1549508
fix(shakedex): refuse a sale of another lock outpoint, pin the sale a…
DimazzzZ Oct 9, 2026
656b5ca
feat(shakedex): read a purchase of our lock coin from hsd's transacti…
DimazzzZ Oct 9, 2026
1aca870
fix(shakedex): refuse a block transaction entry without a txid, and p…
DimazzzZ Oct 9, 2026
c775f2f
feat(shakedex): follow a listing to sale pending and sold on every sy…
DimazzzZ Oct 9, 2026
092c3a5
fix(shakedex): judge the name's owner as a purchase of the stored loc…
DimazzzZ Oct 9, 2026
6ac1be9
feat(shakedex): take a sale or a finalize back on a reorg, follow a r…
DimazzzZ Oct 9, 2026
154ff97
fix(shakedex): move a sold listing back to sale pending or to a compe…
DimazzzZ Oct 9, 2026
5d257e3
fix(shakedex): expire a lock whose name was opened again, sell a dead…
DimazzzZ Oct 9, 2026
ca44b0e
fix(shakedex): sell a locking listing through a proven lock coin, try…
DimazzzZ Oct 9, 2026
4afb8b2
feat(shakedex): restore a lock by name (R32)
DimazzzZ Oct 9, 2026
3b8aae0
feat(shakedex): upgrade a restored lock from its own listing file (R32)
DimazzzZ Oct 9, 2026
d5cea12
feat(shakedex): list locked names with their listing state in owned n…
DimazzzZ Oct 9, 2026
ade719f
test(shakedex): live sold through the cli, on a node without the inde…
DimazzzZ Oct 9, 2026
465b114
fix(shakedex): sell or mark pending only through the listing's stored…
DimazzzZ Oct 9, 2026
1b6289a
refactor(shakedex): one table of listing state transitions
DimazzzZ Oct 9, 2026
5e2ff40
fix(shakedex): judge a coin in a listing's lock by its name as well a…
DimazzzZ Oct 9, 2026
1fc4016
fix(shakedex): store listing txids lowercase and compare them exactly
DimazzzZ Oct 9, 2026
8b73011
feat(shakedex): sell a lock restored by name through a mined transfer…
DimazzzZ Oct 9, 2026
20519bf
refactor(shakedex): share the listing rules the owned-names read and …
DimazzzZ Oct 9, 2026
edaeb4b
fix(shakedex): refuse an own listing file naming a fee address, and c…
DimazzzZ Oct 9, 2026
8cd4443
test(shakedex): live sale of a lock restored by name
DimazzzZ Oct 9, 2026
c15a057
fix(shakedex): sell a lock restored by name only on a price step's 0x…
DimazzzZ Oct 9, 2026
bf705c1
test(shakedex): give fixture covenants hsd's full items and fix a sta…
DimazzzZ Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market

#### 5. Price steps signed by the lock key (selling)

**Mitigation:** The lock key signs a price step (`0x84`, `SINGLEREVERSE|ANYONECANPAY`) only in `commands/shakedex.rs::finalize_and_sign_confirmed`, behind the `shakedex_finalize_and_sign` command, and only after the user confirms the Finalize & sign prompt in the secure window. Before the prompt it makes one `0x84` signature, the R18 self-check's (`sell::lock_self_check`), over a test template with a zero outpoint: it is verified and dropped, never stored or sent. The prompt is `commands/secure_confirm.rs::confirm_rows`, on `prompt_secure`: its own `confirm` request, not the transaction prompt, listing the FINALIZE's network fee, every step with its price and when it becomes valid, the payment address, the lock address and the warning that each signature lets anyone buy at its price until a cancel is mined. A declined prompt signs and writes nothing. Before it asks, `prepare_lock_finalize` refuses unless the listing is the active profile's and ReadyToFinalize, the node reports the name still held, unrevoked, by the lock TRANSFER after its lockup, and that confirmed TRANSFER commits to the lock of the key re-derived from the seed for the name, whose public key must be the one stored on the listing (R18; the key is derived after every node read, held in memory until the command returns, never stored). Each step's template has one input, the lock coin the FINALIZE into the lock creates (that FINALIZE's txid, which Handshake fixes before signing, and the index of its one FINALIZE output paying the lock address, `sell::lock_output`), with that output's value, and one output paying the price to the listing's reserved payment address; its lock time is the node's median time minus 512 seconds, read from the node just before the prompt (with the other node reads, not after it), and nothing is signed when the node does not report a median time. `sell::sign_step` signs that template with the lock key and refuses a signature that does not verify against it (sighash `0x84`, low-S, the lock key's public key). The FINALIZE and every step are signed in one hold of the unlocked session; the signed FINALIZE draft, the steps, the listing file and the listing's move to Finalizing are written in one database transaction, and nothing is sent: `commands/tx.rs::broadcast_tx_draft` sends the FINALIZE through the usual broadcast gates. A step over a FINALIZE that is never mined can never be spent, since any other FINALIZE has another txid; `shakedex_export_listing_file` refuses to hand out the listing file while the listing is Locking, ReadyToFinalize or Finalizing, that is before its FINALIZE is mined, and for a listing that is over (Aborted, Expired, Cancelled, Sold) or Restored.
**Mitigation:** The lock key signs a price step (`0x84`, `SINGLEREVERSE|ANYONECANPAY`) only in `commands/shakedex.rs::finalize_and_sign_confirmed`, behind the `shakedex_finalize_and_sign` command, and only after the user confirms the Finalize & sign prompt in the secure window. Before the prompt it makes one `0x84` signature, the R18 self-check's (`sell::lock_self_check`), over a test template with a zero outpoint: it is verified and dropped, never stored or sent. The prompt is `commands/secure_confirm.rs::confirm_rows`, on `prompt_secure`: its own `confirm` request, not the transaction prompt, listing the FINALIZE's network fee, every step with its price and when it becomes valid, the payment address, the lock address and the warning that each signature lets anyone buy at its price until a cancel is mined. A declined prompt signs and writes nothing. Before it asks, `prepare_lock_finalize` refuses unless the listing is the active profile's and ReadyToFinalize, the node reports the name still held, unrevoked, by the lock TRANSFER after its lockup, and that confirmed TRANSFER commits to the lock of the key re-derived from the seed for the name, whose public key must be the one stored on the listing (R18; the key is derived after every node read, held in memory until the command returns, never stored). Each step's template has one input, the lock coin the FINALIZE into the lock creates (that FINALIZE's txid, which Handshake fixes before signing, and the index of its one FINALIZE output paying the lock address, `sell::lock_output`), with that output's value, and one output paying the price to the listing's reserved payment address; its lock time is the node's median time minus 512 seconds, read from the node just before the prompt (with the other node reads, not after it), and nothing is signed when the node does not report a median time. `sell::sign_step` signs that template with the lock key and refuses a signature that does not verify against it (sighash `0x84`, low-S, the lock key's public key). The FINALIZE and every step are signed in one hold of the unlocked session; the signed FINALIZE draft, the steps, the listing file and the listing's move to Finalizing are written in one database transaction, and nothing is sent: `commands/tx.rs::broadcast_tx_draft` sends the FINALIZE through the usual broadcast gates. A step over a FINALIZE that is never mined can never be spent, since any other FINALIZE has another txid; `shakedex_export_listing_file` refuses to hand out the listing file while the listing is Locking, ReadyToFinalize or Finalizing, that is before its FINALIZE is mined, and for a listing that is over (Aborted, Expired, Cancelled, Sold) or Restored. Restore a lock by name (R32, `shakedex_restore_lock`) derives the lock key from the unlocked session only to read its public key and lock address, after the node reads; it signs nothing. Importing a Restored lock's own listing file (R32, `shakedex_import_own_listing_file`) derives it the same way, after the lock coin is read, only to compare its public key with the file's; it signs nothing either.

---

Expand Down
11 changes: 8 additions & 3 deletions docs/REGTEST_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ These drive REAL reorgs via a `#[cfg(test)]` `invalidateblock`/`reconsiderblock`

### A node without a transaction index (Group C, second node)

hsd finds a transaction by txid only in its mempool and its transaction index, so on a node without `--index-tx` every mined transaction reads as hsd's "Transaction not found.". These tests need a second regtest node started with the address index alone, on ports of its own; they are skipped while `HNS_IT_NOINDEX_NODE_URL` is unset or empty:
hsd finds a transaction by txid only in its mempool and its transaction index, so on a node without `--index-tx` every mined transaction reads as hsd's "Transaction not found.". These tests need a second regtest node started with the address index alone, on ports of its own; they are skipped while `HNS_IT_NOINDEX_NODE_URL` is unset or empty. The node mines its own chain (it does not connect to the first node); `live_noindex_listing_sold_is_detected` also registers a name and runs both lockups on it, about 250 blocks a run:

```bash
hsd --network=regtest --index-address --no-wallet --listen=false \
Expand All @@ -326,6 +326,7 @@ HNS_IT_NOINDEX_NODE_URL=http://127.0.0.1:24037 HNS_IT_NOINDEX_NODE_API_KEY=test
| `live_noindex_unsent_draft_with_unspent_coins_is_dropped` | A draft the node never had, its coins unspent, is `dropped` and its coins released |
| `live_noindex_coins_spent_by_another_tx_give_no_verdict` | A draft whose coin another transaction spent is not sent and stays `broadcast_pending`: without the index the wallet cannot tell that from a mined one |
| `live_noindex_dropped_send_mined_after_all_is_confirmed` | A send judged `dropped` that is then mined is `confirmed`, found by its outputs |
| `live_noindex_listing_sold_is_detected` | Namehold sells, a second Namehold profile buys; with the purchase not found by txid, the listing is Sold through the name's owner (the purchase's TRANSFER out of our lock) and our payment coin; a database copy that syncs only after the buyer's FINALIZE is Sold from the block its payment coin was mined in |

### Covenant actions (Group D)

Expand Down Expand Up @@ -380,7 +381,7 @@ HNS_IT_NODE_URL=http://127.0.0.1:14037 HNS_IT_NODE_API_KEY=test HNS_IT_SHAKEDEX=
cargo test --manifest-path src-tauri/Cargo.toml --lib live_node_it::shakedex_ -- --test-threads=1 --nocapture
```

The tests drive the CLI through `scripts/shakedex-cli-sell.sh`, with the node's hsd wallet as the seller and as the other buyer. Each run registers fresh names (open, two bids, reveal, register, mining between the phases), so a used chain works, up to a point: regtest halves the block subsidy every 2500 blocks (`halvingInterval`), and the tests fund their wallets by mining, so a chain some tens of thousands of blocks tall leaves them too little to spend. A full run of the live suite mines about 10 000 blocks (the Shakedex tests about 3000 of them, registering names and funding buyers), so start over after two runs; the Shakedex buyer refuses to start on a chain past 30 000 blocks and says why; `scripts/regtest.sh reset` starts over. Each test funds an account of its own (`seeded_conn_regtest` hands out a fresh one): the tests once shared account 0, which gathered thousands of coinbases a run, until a sweep of it outgrew a standard transaction and stalled every later test behind an unmined transaction. The R9 test below mines about 5000 blocks and takes them back out with `invalidateblock` before it asserts anything, so it leaves the chain as tall as it found it. The R31 test (`shakedex_lock_refused_near_expiry`) does the same toward its own name's expiry, mining to an address no profile derives, and a guard takes the blocks back out even if the test panics first. The lock tests (`shakedex_lock_*`, `shakedex_lock_finalize_pays_its_fee_rate_on_vsize` among them, `shakedex_cancel_transfer_aborts_the_listing_on_chain`, `shakedex_listing_follows_reorg_of_its_lock_finalize` and `shakedex_finalize_from_another_device_is_a_restored_lock`) are gated by `HNS_IT_SHAKEDEX` like the rest, but use no CLI: they register a name with the wallet itself, so they also run against a node at another port. The script clones `shadstoneofficial/shakedex` at the pinned commit `2c4fa04eab68a528e758598d11b5da5666113b11` into `SHAKEDEX_WORK` once and keeps the CLI's database there (the tests default it to `$TMPDIR/namehold-shakedex-cli`; run by hand, the script uses a fresh temp dir unless it is set); nothing is installed globally. `HNS_IT_SHAKEDEX` set to anything but `1` or empty, or set to `1` without `HNS_IT_NODE_URL`, or with a node URL other than the regtest RPC port (14037), fails the tests instead of skipping them. It can be run by hand too:
The tests drive the CLI through `scripts/shakedex-cli-sell.sh`, with the node's hsd wallet as the seller and as the other buyer. Each run registers fresh names (open, two bids, reveal, register, mining between the phases), so a used chain works, up to a point: regtest halves the block subsidy every 2500 blocks (`halvingInterval`), and the tests fund their wallets by mining, so a chain some tens of thousands of blocks tall leaves them too little to spend. A full run of the live suite mines about 10 000 blocks (the Shakedex tests about 3000 of them, registering names and funding buyers), so start over after two runs; the Shakedex buyer refuses to start on a chain past 30 000 blocks and says why; `scripts/regtest.sh reset` starts over. Each test funds an account of its own (`seeded_conn_regtest` hands out a fresh one): the tests once shared account 0, which gathered thousands of coinbases a run, until a sweep of it outgrew a standard transaction and stalled every later test behind an unmined transaction. The R9 test below mines about 5000 blocks and takes them back out with `invalidateblock` before it asserts anything, so it leaves the chain as tall as it found it. The R31 test (`shakedex_lock_refused_near_expiry`) does the same toward its own name's expiry, mining to an address no profile derives, and a guard takes the blocks back out even if the test panics first; `shakedex_listed_lock_expires_with_its_name` mines about 5000 blocks to its Listed name's expiry under the same guard. The lock tests (`shakedex_lock_*`, `shakedex_lock_finalize_pays_its_fee_rate_on_vsize` among them, `shakedex_cancel_transfer_aborts_the_listing_on_chain`, `shakedex_listing_follows_reorg_of_its_lock_finalize`, `shakedex_finalize_from_another_device_is_a_restored_lock`, `shakedex_listed_lock_expires_with_its_name` and `shakedex_restore_lock_by_name_after_a_fresh_profile`) are gated by `HNS_IT_SHAKEDEX` like the rest, but use no CLI: they register a name with the wallet itself, so they also run against a node at another port. The script clones `shadstoneofficial/shakedex` at the pinned commit `2c4fa04eab68a528e758598d11b5da5666113b11` into `SHAKEDEX_WORK` once and keeps the CLI's database there (the tests default it to `$TMPDIR/namehold-shakedex-cli`; run by hand, the script uses a fresh temp dir unless it is set); nothing is installed globally. `HNS_IT_SHAKEDEX` set to anything but `1` or empty, or set to `1` without `HNS_IT_NODE_URL`, or with a node URL other than the regtest RPC port (14037), fails the tests instead of skipping them. It can be run by hand too:

| Command | What the CLI does |
|---|---|
Expand All @@ -395,8 +396,12 @@ What the tests check:

- `shakedex_cli_listing_is_bought` — a fixed-price listing is bought: unconfirmed in the mempool, awaiting finalize once mined, owned after the lockup and the FINALIZE, with the node's owner coin paying the purchase's destination.
- `shakedex_namehold_buy_now_is_bought_by_the_cli` — Namehold locks a fresh name, waits out the lockup (the listing jobs make the listing ready only at the last block), Finalize & signs a Buy Now, sends and mines the FINALIZE (Listed once the jobs see it mined), exports the listing file, and the CLI's hsd wallet buys that file byte for byte with `fill`; hsd shows the name's owner as the fill's TRANSFER out of our lock coin, committing to an address of that wallet, and the fill paying our payment address exactly the price.
- `shakedex_listing_follows_reorg_of_its_lock_finalize` (no CLI) — a Listed listing's FINALIZE into the lock has its block invalidated; while hsd has it nowhere (`invalidateblock` empties the mempool) the listing stays Listed, handed back to the node it is a coin in the mempool and the listing is Finalizing, and mined again it is Listed.
- `shakedex_listing_follows_reorg_of_its_lock_finalize` (no CLI) — a Listed listing's FINALIZE into the lock has its block invalidated; while hsd has it nowhere (`invalidateblock` empties the mempool) the lock TRANSFER is a coin again and the listing is Finalizing; handed back to the node it is a coin in the mempool and the listing stays Finalizing; mined again, it is Listed.
- `shakedex_finalize_from_another_device_is_a_restored_lock` (no CLI) — a copy of the wallet's database, as another device restored from the same recovery phrase, Finalize & signs and mines the FINALIZE; the first wallet's listing, still ReadyToFinalize, becomes a Restored lock with that FINALIZE's lock outpoint, read from hsd's owner coin.
- `shakedex_namehold_listing_sold_to_the_cli_is_sold` — the CLI's hsd wallet buys our exported Buy Now; hsd's owner is the fill's TRANSFER out of our lock, the fill pays our payment address the price, and after a sync the listing is Sold with the fill's txid; the fill's block invalidated, the lock coin is a coin again and the listing Listed; the fill handed back, SalePending with the owner still the lock coin; mined again, Sold.
- `shakedex_listed_lock_expires_with_its_name` (no CLI) — a Listed lock's name is mined to hsd's `renewalPeriodEnd` (the FINALIZE into the lock's block plus the renewal window) to an address no profile derives: one block before, the listing stays Listed; at it hsd reports no live name while the lock coin is still a coin, and the listing is Expired. The blocks are taken back out, by a guard even if the test panics.
- `shakedex_restore_lock_by_name_after_a_fresh_profile` (no CLI) — a fresh profile from the same phrase and account, with a restore's address window, restores the Listed lock by name, as hsd's owner coin, a FINALIZE at the lock address derived from the phrase; a second restore is refused; the first device's saved file, imported after a sync, makes it Listed with that file's payment address. The payment address sits inside the default gap, so the test does not exercise the known restore-window gap (spec R21 and §5: the window is never extended, and a payment address reserved past it is refused until it is); that case is not tested live.
- `shakedex_restored_lock_by_name_bought_is_sold` (no CLI) — a fresh profile from the same phrase restores the Listed lock by name, so it knows no payment address; a Namehold buyer buys the first device's file: in the mempool (lock coin hsd's 404, owner still the lock coin) the restored lock stays Restored, and once mined (owner the purchase's TRANSFER out of the lock, linked from the stored outpoint) it is Sold with the purchase's txid.
- `shakedex_finalized_name_moved_on_before_a_sync_is_owned` — the purchase is finalized and the name then sent elsewhere (TRANSFER, lockup, FINALIZE) before the purchase job runs again; the job finds our FINALIZE as the TRANSFER's spender in the node's history of our destination and marks the purchase owned. The shakedex lock script lets the TRANSFER be spent only into a FINALIZE, so there is no other ending to test.
- `shakedex_cli_buyer_first_loses_ours_with_nothing_paid` — the CLI buys the listing between our review and our broadcast. hsd 8.0.0's `sendrawtransaction` answers with the txid anyway; the look-up after it finds the node did not take the purchase, so the broadcast says "did not take" and the draft waits as `broadcast_pending`. The purchase job then finds the lock coin spent and loses the purchase with nothing paid, its coins free again, and the listing is no longer offered.
- `shakedex_purchase_follows_reorgs_of_its_own_blocks` — the purchase's block is invalidated (hsd's `invalidateblock` empties the mempool too, so the purchase is gone from the node), the app's sync rebroadcasts it once after six blocks missing, and it is mined again; then the FINALIZE's block is invalidated and the purchase awaits finalize again until the same FINALIZE is mined.
Expand Down
Loading
Loading