Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
aa660c0
refactor(wallet): one gate for the unlocked signer
DimazzzZ Oct 8, 2026
39897df
feat(shakedex): store listings and their states
DimazzzZ Oct 8, 2026
9778418
fix(shakedex): keep a locking listing whose lock draft may have been …
DimazzzZ Oct 8, 2026
22dcad4
feat(shakedex): the lock transfer's covenant against hsd
DimazzzZ Oct 8, 2026
a90ca95
feat(shakedex): refuse a lock the name would not survive
DimazzzZ Oct 8, 2026
9d9bfb2
feat(shakedex): lock a name for sale
DimazzzZ Oct 8, 2026
9795baf
fix(names): count a cancel in flight as an owner spend
DimazzzZ Oct 8, 2026
473fd33
feat(shakedex): offer no owner action on a name locked for sale
DimazzzZ Oct 8, 2026
833264f
fix(shakedex): refuse revoke on a listed name and free a dead lock
DimazzzZ Oct 8, 2026
06ebd0e
feat(shakedex): Cancel transfer aborts a listing still locking
DimazzzZ Oct 8, 2026
ed0b2d7
fix(shakedex): keep the abort link of a cancel that was broadcast
DimazzzZ Oct 8, 2026
1c5e79a
fix(shakedex): keep the cancel's txid on the listing it aborts
DimazzzZ Oct 9, 2026
2e0d98f
test(shakedex): lock, abort and the near-expiry refusal on a live node
DimazzzZ Oct 9, 2026
9bc2f7a
refactor(shakedex): share the draft persistence and the status sets w…
DimazzzZ Oct 9, 2026
5ad4523
test(shakedex): a cancel of another output of the lock tx links nothing
DimazzzZ Oct 9, 2026
35323ce
fix(shakedex): resolve a listing before its lock from chain facts
DimazzzZ Oct 9, 2026
8a90bf3
fix(shakedex): judge R31 again when the lock is sent
DimazzzZ Oct 9, 2026
4c4bdf2
test(shakedex): gate the lock live tests and rewind the near-expiry c…
DimazzzZ Oct 9, 2026
a8fa495
docs(changelog): Renew Selected and a Cancel transfer in flight
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
- **Remote-node onboarding** — the first-run flow now opens with a "How do you want to connect?" step offering three choices: **Local full node** (default; start hsd on this device), **Remote node** (point at an existing hsd RPC, with a "Test connection" button that probes the node before you commit), and **SPV** (lightweight headers-only, read-only). Choosing a source persists `chain_source` + `node_mode` (plus `node_rpc_url` / API key for Remote node) up front so a new user reaches a working read+send wallet without waiting for a full local sync. Your recovery phrase never leaves the device — remote/SPV is a privacy/trust tradeoff, not custody. New Tauri command `check_node_connection` validates a candidate RPC (reachable / height / synced, and whether the node's network matches the wallet's — during onboarding, the network you picked on the previous step; a cross-network node, e.g. testnet-for-mainnet, is flagged with an amber warning under the "Connected" line and is not treated as a usable node) without persisting anything, honoring the existing plaintext-key / non-loopback transport guard. Settings' Chain source selector now offers the same four modes (local full / SPV / remote / explorer) and replaces the separate Node mode dropdown; Settings also gained the same "Test connection" affordance and an "Allow sending via remote node" toggle (`allow_remote_broadcast`, off by default) — the toggle appears both on the onboarding Remote step and in Settings.

### Fixed
- **Renew Selected is offered only when every selected name can be renewed now.** The button was enabled for any selection, so one holding a name that could not be renewed yet (one renewed too recently, say) only ended in a refusal. It is now disabled, with a hint that all selected names must be renewable now.
- **A Cancel transfer in flight holds the name's other owner actions until it confirms.** The wallet looked for a sent cancel under the wrong action name, so it did not count it among the transactions that spend the name's owner coin. A cancel that is sent but not yet mined now keeps the name shown as yours, with its other owner actions held until the cancel confirms, as every other owner action does.
- **A send the node did not take says "Not sent".** hsd answers `sendrawtransaction` with the txid whatever its mempool does with the transaction, so a send whose coins were already spent, or that the mempool turned away for any other reason, read as sent and was quietly marked dropped ten minutes later. The wallet now looks the transaction up right after sending (up to five times over two seconds, since hsd adds it to its mempool after answering). When the node does not have it, the dialog says "The node did not take the transaction", the draft waits with its coins held, and the confirmation check resolves it later: dropped, saying another transaction spent its coins, when that is what the chain shows. A send already mined by the time it is looked up (a node without a transaction index finds it only by its outputs) reads as confirmed. A Shakedex purchase follows the same rule, and its one automatic rebroadcast too: a rebroadcast the node does not take loses the purchase with nothing paid.
- **A renewal, finalize or redeem the network would refuse is refused before it is signed.** hsd answers `sendrawtransaction` with the txid even when its consensus rules turn the transaction away, so three such name actions read as sent. A renewal right after the name was registered, renewed or finalized (hsd accepts one only a tree interval later, 36 blocks on mainnet) now says how many blocks are left, and Renew waits that long before it is offered. A finalize inside the transfer lockup says how long the lockup still runs. A redeem never spends the winning reveal, which the network refuses to redeem because until REGISTER it owns the name: the wallet now asks the node which coin owns the name instead of relying on its own records, which knew nothing of a name it never tracked, and when that reveal is all there is it says the bid won and the name should be registered. Each check reads the name and the chain tip from the node at the moment the draft is built. A batch redeem also takes every losing reveal on a name, as the single redeem does, instead of only the newest bid's.
- **A send already sent, dropped or failed is not signed or sent again.** Signing or broadcasting such a draft again would turn a confirmed one back into "signed", or spend coins a dropped or failed one had released, which another draft may hold by now. Both are refused before anything reaches the node; build a new send instead.
Expand Down
3 changes: 2 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market

#### 3. Signing an input the wallet does not own

**Mitigation:** The seller's lock coin is a foreign input carrying its own witness; the wallet signs only its own inputs. A Ledger signs every input as the wallet's own P2WPKH, so a plan with a foreign input, a custom sequence or a lock time is refused twice: when the draft is signed (`commands::tx`) and in the Ledger signer itself (`providers::ledger::signing`), which sees only the plan's shape. Draft signing refuses every Shakedex draft for a Ledger by its action as a class (`shakedex::is_shakedex_action`), because the FINALIZE into a lock is an ordinary plan the device signer cannot tell from any other; a plan with a lock-key or foreign input is refused by its shape. A lock coin of ours is spent only by a cancel, signed with the lock key, which the signer derives from the seed at that moment and never stores. The signer refuses an input that is both foreign and lock-key (`sign_plan_tx`), and refuses a lock-key input unless it is a `0x83` (`ANYONECANPAY|SINGLE`) input with a final sequence in a plan with lock time 0, from a receive-branch path at an unhardened index, whose output at the same index is a TRANSFER at that key's lock address with exactly 4 items, of this name (item 0 is the name hash), to a version-0 address (item 2 is `00`) whose hash (item 3) is our own receive address re-derived from the input's path (`noncustodial/shakedex/cancel.rs::check_lock_key_input`).
**Mitigation:** The seller's lock coin is a foreign input carrying its own witness; the wallet signs only its own inputs. A Ledger signs every input as the wallet's own P2WPKH, so a plan with a foreign input, a custom sequence or a lock time is refused twice: when the draft is signed (`commands::tx`) and in the Ledger signer itself (`providers::ledger::signing`), which sees only the plan's shape. Draft signing refuses every Shakedex draft for a Ledger by its action as a class (`shakedex::is_shakedex_action`), because the FINALIZE into a lock is an ordinary plan the device signer cannot tell from any other; a plan with a lock-key or foreign input is refused by its shape. A lock coin of ours is spent only by a cancel, signed with the lock key, which the signer derives from the seed at that moment and never stores. Locking a name (day 0) derives the name's lock key from the unlocked seed as well, only to read its public key and run the R18 self-check, whose test signature is over a template with a zero outpoint and is checked and dropped; only the public key is stored on the listing, and the TRANSFER commits to the program of the key just checked. The signer refuses an input that is both foreign and lock-key (`sign_plan_tx`), and refuses a lock-key input unless it is a `0x83` (`ANYONECANPAY|SINGLE`) input with a final sequence in a plan with lock time 0, from a receive-branch path at an unhardened index, whose output at the same index is a TRANSFER at that key's lock address with exactly 4 items, of this name (item 0 is the name hash), to a version-0 address (item 2 is `00`) whose hash (item 3) is our own receive address re-derived from the input's path (`noncustodial/shakedex/cancel.rs::check_lock_key_input`).

#### 4. The daemon and purchases

Expand All @@ -267,6 +267,7 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market
| Tampered listing or price | Verified on the profile's node; price re-checked before broadcast | `noncustodial/shakedex/verify.rs` | `shakedex_verify_tests`, `shakedex_cmd_tests` |
| Ledger signs a foreign input, a lock coin or a Shakedex draft | Draft signing refuses every Shakedex draft by action and a lock-key or foreign plan by shape; the device signer refuses foreign, lock-key and custom-sequence or lock-time plans by shape only, and cannot see the action | `commands/tx.rs`, `providers/ledger/signing.rs` | `ledger_plan_guard_tests` |
| Lock key signs anything but a cancel | Signer accepts only a `0x83` input whose same-index output is a TRANSFER of the name at its lock address to our own version-0 receive address, and refuses an input that is both foreign and lock-key | `noncustodial/shakedex/cancel.rs` (`check_lock_key_input`), `noncustodial/actions.rs` (`sign_plan_tx`) | `actions::tests::sign_plan_refuses_a_lock_key_input_that_is_not_a_cancel` |
| Name locked to a key that cannot move it | The lock key is re-derived and self-checked (script, program, address, a test `0x84` signature) before the TRANSFER draft is written; the TRANSFER commits to that key's program | `noncustodial/shakedex/sell.rs` (`lock_self_check`, `lock_transfer_covenant`), `commands/shakedex.rs` (`build_lock_draft_inner`) | `shakedex_sell_tests::{refuses_lock_on_self_check_failure, lock_draft_commits_to_the_derived_lock_address}`, `shakedex_vector_tests::lock_transfer_covenant_matches_hsd` |
| Daemon rebroadcasts a purchase | `SyncCaller::Daemon` never rebroadcasts | `commands/sync.rs`, `shakedex_jobs.rs` | `shakedex_purchase_state_tests::{daemon_sync_makes_no_send_call_where_the_apps_sync_does, daemon_never_rebroadcasts_and_leaves_it_to_the_app}` |

---
Expand Down
2 changes: 1 addition & 1 deletion docs/REGTEST_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,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 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. The T2 lock tests (`shakedex_lock_*`, `shakedex_cancel_transfer_aborts_the_listing_on_chain`) 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 Down
Loading
Loading