diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c9bbceb..a3bf6db7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Lint secure window imports run: pnpm lint:secure-imports + - name: Lint native title attributes + run: pnpm lint:native-title + - name: Format check run: pnpm lint:format diff --git a/CHANGELOG.md b/CHANGELOG.md index ba3c2ef2..2653c9f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,18 @@ ### Added - **Several bids on one name.** The wallet allowed a single bid per name per wallet; it now allows as many as you like, each with its own value and lockup, the way Namebase does. Every bid rotates to a fresh receive address, so each gets its own nonce, blind, commitment row and BID coin, and the send-side guards that refused a second one are gone. The name modal's header reads "Latest bid … · lockup … · N of yours", and the bids panel keeps every bid in one list with your own rows tinted, so which are yours is visible without counting. -- **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 — once a wallet profile exists — whether the node's network matches the wallet's; 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. +- **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 +- **The batch confirmation states the amount it is about to sign.** It showed the count and the fee and left out the one figure that changes with the batch: what the transaction moves, summed over every output except change. A batch reveal or redeem carries one output per bid, so that line is where you see how much is in play. +- **Redeeming no longer builds a transaction the node would reject when the wallet cannot read its own records.** The redeem builder keeps the winning reveal out of the transaction, because Handshake refuses to redeem the coin that owns the name; if the lookup that finds that coin failed, the builder carried on without the filter. It now reports the failure instead. In the same spirit, the watched-name alerts and the readiness check behind local reads no longer treat "could not read which network this wallet is on" as "nothing to compare", which let a node on another chain count as ready. +- **Name reads no longer fall back to the mainnet explorer when they cannot tell which chain you are on.** Looking up a name, its bids or its DNS records picked an explorer from a network that quietly answered "mainnet" whenever it could not be read, so a testnet or regtest wallet could be shown a mainnet name's auction phase, someone else's bids and someone else's records. Each of those reads now treats an unreadable network as unknown and serves nothing rather than another chain's data. Looking up bids also asks the profile it was given rather than whichever profile happens to be selected. +- **Re-syncing the chain asks which network it is about to move.** The one-click re-sync stops the node, moves the current chain data to a timestamped backup and starts a fresh sync. It worked out the directory and the network with two separate reads, either of which quietly answered "mainnet" when it could not tell — so with no wallet selected it would back up, and then re-sync over, a directory belonging to a network you are not on. It now resolves the network once and refuses when there is none, the way starting a node already did. +- **The manual and README no longer advertise paid name swaps.** The two buttons were withdrawn because the shape they implemented could not be atomic and could only be pressed by the party with nobody to pay, but the manual still walked you through using them, the README still promised that neither party could renege, and the QA checklist still looked for them. All three now say the feature is gone and what remains: an offer recorded earlier can still be claimed. The manual also had a note admitting its own batch-operations section was inaccurate; the section now says what the wallet does instead. +- **The guided panel no longer tells you a name allows one bid each.** Bidding a second time on a name has been allowed for a while, but a panel left over from the old rule still said otherwise, and it could only appear in a state the wallet stopped producing at the same time. The bid form now stays offered for the whole bidding window, however many bids you already hold. Settings also reads the node's sync verdict from the backend instead of working it out a second time, so the label and what reads actually do cannot disagree. +- **The About link in the sidebar has a name again for screen readers.** It is an icon with no text, so its old `title` was also its accessible name; converting the sidebar's hints to tooltips took the `title` away without putting an `aria-label` in its place, leaving the link announced as nothing. The keyboard-shortcuts button beside it already carried one. +- **Each wallet profile now really uses its own node.** Per-profile node configuration was stored and resolved, but several paths still asked the global settings: the node the app started took the global api-key, the background sync decided whether "the node" was caught up by asking the global one while every step it gated talked to the profile's, and a sync failure named the global URL in a message telling you to go fix it. Worse, a profile whose own node configuration would not resolve was quietly served the global node instead of being reported as misconfigured, so a profile pinned to a regtest node could be answered by a mainnet one. Each of those now resolves through the profile, and an unresolvable profile configuration is an error rather than somebody else's node. The automatic repair of a stale loopback port also stops rewriting a URL you set on the profile yourself. +- **A testnet or regtest wallet no longer reads from the mainnet explorer.** An early migration seeded `explorer_api_url` with the mainnet explorer, and a later change swapped that seed for an empty string so the wallet could pick an explorer per the profile's network. The swap only helped databases created after it: every installation that had already run the original kept the mainnet URL, and an explicit setting outranks the network default, so a testnet or regtest profile went on querying mainnet and getting confident answers about a chain it was not on. A new migration removes exactly the value the old one seeded, leaving an explorer URL you chose yourself alone, and the explorer factory now refuses the known mainnet explorer off mainnet in case a database reaches it before the migration has run. - **Reveal is no longer offered on a name with nothing left to reveal.** A lockup stranded in an auction that lapsed is an unspent BID coin, and the Reveal button was gated on holding one of those anywhere in the wallet rather than in the auction being looked at. So on a fully revealed name the button stayed live for good and failed every time with "no unspent bid coin". It now reads the same set the reveal transaction is built from, so the button and the builder cannot disagree. - **Registering no longer looks as though DNS records are required.** The Register step put a record editor in front of the user and said nothing about it. Records are optional — Handshake accepts an empty resource, and the wallet was already sending one when the editor was untouched — so the panel now says registering claims the name and records can follow with Update, and keeps the editor behind "Add DNS records now (optional)". Register also stopped appearing twice, live in both the guided step and the records section, and the manual auction actions now show only what the stage allows, each with a line saying what pressing it does — Redeem names the amount it reclaims. - **The name status no longer contradicts the modal it opens.** The Owned Names table printed the auction phase while the modal printed the task, so a row reading "Closed" opened a modal headed "Won — Register Now". The table now shows the same summary the auctions list and the modal do, and defers to what is in flight when a transaction of yours is waiting for a block. The phase was nearly a constant down that column anyway: every name you own has a closed auction. diff --git a/CONTEXT.md b/CONTEXT.md index 195572ec..2c41526f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -28,9 +28,10 @@ _Avoid_: Sync mode The tuple `(node_rpc_url, node_rpc_api_key, chain_source)` that tells the wallet how to reach a node. Can be global (applies to all profiles) or per-profile (applies only to that profile). Per-profile overrides the global. _Avoid_: Connection settings (too vague) -**Per-slot per-profile override**: -A per-profile override of a single slot's source that takes precedence over the global default for that slot. A profile resolves two independent slots — the read slot and the write (send) slot — and each carries its own override flag. A profile may override its read slot (e.g. read via explorer) while inheriting the write slot from global, or vice versa. Stored in `profile_settings` table. If an override is set and invalid (unreachable, mismatched network), it is a configuration error for that slot, not a fallback to global. -_Avoid_: Per-profile override (imprecise now that override is per-slot), profile-specific node, profile node setting +**Per-profile override**: +A per-profile choice of node configuration that takes precedence over the global default. Stored in the `profile_settings` table, one row per key. If an override is set and invalid (unreachable, mismatched network), it is a configuration error for that profile, not a fallback to global. +_Planned, not built_: splitting this per *slot*, so a profile could override its read slot (e.g. read via explorer) while inheriting the write slot from global. The table has no slot column and resolution returns one tuple; see step 8 of the [per-profile node spec](./docs/specs/2026-09-15-per-profile-node-banner-and-preflight.md). Until then "per-slot per-profile override" names something the wallet does not do. +_Avoid_: Profile-specific node, profile node setting **Preflight**: A check performed before an operation (sync, read, broadcast) to ensure the node is reachable and on the correct network for the active profile. Returns a status (ready, missing, misconfigured) and optionally a suggested fix. @@ -41,5 +42,5 @@ The node's reported chain (from `getblockchaininfo`) does not match the profile' _Avoid_: Chain mismatch (same thing, but "network" is the profile's term) **Effective node config**: -The resolved sources for a profile after applying the resolution order per slot: per-slot per-profile override (if set) → global settings (if set) → built-in default. Resolution runs independently for the read slot and the write (send) slot, so the effective read source and effective write source can differ (e.g. read via explorer, write via a local node). Each resolved slot is a `(node_rpc_url, node_rpc_api_key, chain_source)` tuple; the write slot may resolve to none when no node is available to send through. +The resolved node configuration for a profile after applying the resolution order, evaluated independently per key: per-profile override (if set and non-empty) → global settings (if set) → built-in default. The result is one `(node_rpc_url, node_rpc_api_key, chain_source)` tuple, plus whether the profile's own override supplied it — which is what keeps the loopback-port realign off a URL the user chose. A profile that does not resolve is an error, never a fallback to global. _Avoid_: Resolved config (same meaning, but "effective" emphasizes the resolution order) diff --git a/README.md b/README.md index b6dc3cbf..10c7d49a 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,6 @@ Built with Tauri v2, React + TypeScript, Rust, and SQLite. - **Name watchlist** — track names you don't own for monitoring. Watchlist page in the sidebar with add/remove, tags, CSV import/export, and "Add to Watchlist" buttons in name modals. -- **Paid name swaps** — atomic buyer-seller name transfer with payment. The buyer - finalizes a TRANSFER and pays the seller in a single transaction - (finalizeWithPayment), so neither party can renege after the lockup expires. - **Recover lost bids** — if you lose your local bid data (reinstall, seed- restore, import from another wallet), Namehold can brute-force the bid value from your seed and reveal it before the window closes. See @@ -69,7 +66,8 @@ Built with Tauri v2, React + TypeScript, Rust, and SQLite. ### Node-free reads - Reads are **node-free by default** via the HNSFans explorer — no node required - just to view your wallet. When your local hsd is synced, the app automatically + just to view your wallet. Only mainnet has a built-in explorer; testnet and + regtest read node-free only with an explorer URL you configure. When your local hsd is synced, the app automatically switches to **node-authoritative** reads (owned names, balances, bid history) for faster, more reliable data. A local node is needed **only to send or perform name actions**. @@ -128,8 +126,8 @@ Built with Tauri v2, React + TypeScript, Rust, and SQLite. ## How it works -- **Reads are node-free.** Balances and names come from the explorer and are cached - locally per wallet. Links to transactions, names, and addresses open on Shakeshift. +- **Reads are node-free** on mainnet. Balances and names come from the explorer + and are cached locally per wallet; other networks need an explorer URL of yours. Links to transactions, names, and addresses open on Shakeshift. - **Sending needs a node.** Broadcasting and coin/owner discovery use a local **hsd** node over RPC. The app can start/stop hsd for you (Settings → Connections). - **Secrets stay in a secure window.** Your mnemonic/passphrase is only ever typed diff --git a/docs/NODE_SETUP.md b/docs/NODE_SETUP.md index d8f53658..9387b055 100644 --- a/docs/NODE_SETUP.md +++ b/docs/NODE_SETUP.md @@ -57,13 +57,13 @@ Instead of running hsd locally, you can point the wallet at an existing hsd RPC: back), so "Test connection" reuses your stored key when the URL you probe matches the saved node. A freshly typed URL is probed without the stored key — the secret is never sent to an endpoint you just typed. -- **Network mismatch:** once a wallet profile exists, "Test connection" compares the node's - reported network with your wallet's and flags a mismatch (e.g. a testnet node for a - mainnet wallet). A mismatched node is refused everywhere: the app will not read from it, - will not sync from it, reports it as unable to send, and refuses to broadcast through it — - the signed transaction never leaves your device. Settings will not save one either. - During first-run onboarding there is no wallet yet, so the comparison starts applying in - Settings. +- **Network mismatch:** "Test connection" compares the node's reported network with the + wallet's and flags a mismatch (e.g. a testnet node for a mainnet wallet). A mismatched + node is refused everywhere: the app will not read from it, will not sync from it, reports + it as unable to send, and refuses to broadcast through it — the signed transaction never + leaves your device. Settings will not save one either. This applies during first-run + onboarding too: there is no wallet profile yet, so the comparison uses the network you + picked on the previous step. - **To send:** enable "Allow sending via remote node" (off by default for safety; shown in the onboarding Remote step and in Settings → Connections). This sets the `allow_remote_broadcast` flag, which gates the broadcast path. Your recovery phrase @@ -118,15 +118,38 @@ rows inside the shared database or in the node's own datadir. | Path | What it holds | |------|---------------| -| `~/.namehold/portfolio.db` | The shared SQLite database: wallet profiles, portfolio (UTXOs, name states, transactions), and **all node configuration** (global + per-profile `node_rpc_url`, `node_rpc_api_key`, `chain_source`, per-slot overrides). Used by both the GUI and the `namehold-syncd` daemon. | +| `~/.namehold/portfolio.db` | The shared SQLite database: wallet profiles, portfolio (UTXOs, name states, transactions), and **all node configuration** (global + per-profile `node_rpc_url`, `node_rpc_api_key`, `chain_source`). Used by both the GUI and the `namehold-syncd` daemon. | | `~/.namehold/syncd.pid` | PID file for the background sync daemon (see above). | -**Node RPC config is not stored in files.** The regtest/testnet/mainnet -connection settings you enter under **Settings → Node RPC** are written as rows -in `portfolio.db` (keyed by wallet profile, plus a global default), not as a -per-network config file. Resolution runs independently for the read slot and -the write (send) slot: per-slot per-profile override → global setting → -built-in default. +**Node RPC config is not stored in files.** The connection settings you enter +under **Settings → Connections** are written as rows in `portfolio.db` (a global +default, plus per-profile overrides), not as a per-network config file. Each key +resolves on its own: per-profile override → global setting → built-in default. +Splitting the read and write sides so a profile could read through one source +and send through another is planned, not built — see step 8 of +`docs/specs/2026-09-15-per-profile-node-banner-and-preflight.md`. + +### When the wallet calls a node "synced" + +The chain tip is the test: the node is synced once the blocks it has applied +have caught up to the best header it knows about. `verificationprogress` only +corroborates that, because it can plateau just below 1.0 — around 0.9997 on +regtest — and a node sitting at the tip would otherwise never qualify. To keep +a node that reports `blocks == headers` while barely verified from passing, +progress (when reported at all) must also clear a loose 0.999 floor. + +Two fallbacks matter in practice: + +- **No header height reported.** Older builds and some nodes do not send one. + The wallet falls back to `verificationprogress >= 0.9999`. +- **Neither reported.** A regtest node with a single miner reports no sync + metadata at all. A gate on a node you configured assumes synced, so regtest + keeps working; a first-contact probe of a node you have just typed in assumes + the opposite, so an unknown node is not trusted on no evidence. + +One rule answers this everywhere — the read gate, the write gate, the node +status panel and "Test connection" — so the label in Settings cannot disagree +with what reads actually do. ### Node (hsd) datadirs and default RPC ports @@ -158,7 +181,9 @@ For users who don't need to send transactions or want faster initial setup: explorer. - **Explorer failover** — set a fallback URL in Settings for when the primary explorer is unreachable. -- **To enable:** Settings → Connections → Node mode → select "SPV" → Save. +- **To enable:** Settings → Connections → Chain source → select "Local node (SPV)" + → Save. There is no separate "Node mode" dropdown; the one selector sets both + the source and, for a local node, whether it runs full or SPV. - **Status indicator:** StatusStrip shows "Explorer (SPV)" when SPV mode is active. SPV mode is ideal for: diff --git a/docs/QA_WORKFLOWS.md b/docs/QA_WORKFLOWS.md index 520f8c75..654488e6 100644 --- a/docs/QA_WORKFLOWS.md +++ b/docs/QA_WORKFLOWS.md @@ -208,7 +208,7 @@ hsd-cli rpc generate 1440 # advance through REVEAL phase ### H. Watchlist - [ ] Watchlist page visible in sidebar -- [ ] Add a name to the watchlist from NameActionsModal or NameInfoModal +- [ ] Add a name to the watchlist from the name modal - [ ] Remove a name from the watchlist - [ ] Watchlist table shows Countdown, Highest bid, Expires columns - [ ] Owned names get an inline Owned badge @@ -221,15 +221,18 @@ hsd-cli rpc generate 1440 # advance through REVEAL phase ### I. Batch operations - [ ] Multi-select checkboxes appear on the Owned Names table - [ ] Selecting 2+ names shows the batch action bar -- [ ] "Renew Selected" opens BatchConfirmModal with count + estimated fee + name list +- [ ] "Renew Selected" opens BatchConfirmModal with count + amount + estimated fee + name list - [ ] Confirming broadcasts a single batch transaction - [ ] "Reveal Selected" / "Redeem Selected" / "Finalize Selected" work similarly +- [ ] "Transfer Selected" takes one recipient in the action bar and repeats it in the modal - [ ] Deselecting all hides the batch action bar -### J. Paid name swaps -- [ ] Names in TRANSFER state show "Buy with payment" button (buyer side) -- [ ] "Sell with payment" flow available for owned names in TRANSFER -- [ ] Saved offers list shows pending offers +### J. Paid name swaps (withdrawn) +The two entry points were removed on 2026-09-21; see +`docs/specs/2026-09-21-paid-name-swaps.md`. What remains is the claim path for +an offer recorded before that. +- [ ] No "Buy with payment" or "Sell with payment" button appears on any name +- [ ] Saved offers list shows pending offers recorded earlier - [ ] claim_paid_transfer verifies the broadcast tx before marking paid ### K. Bid recovery diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index ac0ae9f2..7532c12a 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -185,9 +185,9 @@ You can write when **all** of these hold: 1. **Signer unlockable** — a wallet is loaded and either unlocked or has a passphrase you can enter. 2. **Node reachable** — local hsd RPC responds. -3. **Node synced** — hsd's `verification_progress` is ≥ 99.99% (or blocks meet - headers on a network without a progress value, e.g. regtest with a single - miner). +3. **Node synced** — the blocks hsd has applied have caught up to the best + header it knows about; `verification_progress` only corroborates that (see + "When the wallet calls a node synced" in [NODE_SETUP.md](NODE_SETUP.md)). 4. **Address-indexed** — hsd was started with `--index-address` (required to discover your unspent coins). @@ -253,6 +253,20 @@ and broadcasts. The transaction ID and new phase appear on the next refresh. Both are entered in HNS. +### Several bids on one name + +You can bid on the same name as many times as you like while it is in +Bidding, each bid with its own value and lockup. Every bid goes to a fresh +address and gets its own commitment, so each is revealed and reclaimed on its +own. The Name Actions modal's header reads "Latest bid … · lockup … · N of +yours", and the bids panel lists every bid on the name with yours tinted. +**Reveal** and **Redeem** act on all of your bids on that name at once, and +the confirm dialog sums every output they carry. + +A bid you did not reveal in time keeps its lockup on-chain. If you bid on a +name again in a later auction, the guided panel names that stranded lockup and +its amount, so the money is not mistaken for part of the new bid. + ### Active Auctions The Auctions page shows all names you currently have positions in — pending @@ -347,7 +361,6 @@ In the Name Actions modal for an owned name, click **Show all actions**: | **Finalize** | Complete a transfer after the lockup period (mainnet: ~2 days). | | **Cancel** | Revert a pending transfer before it's finalized. | | **Revoke** | Permanently burn the name (irreversible). | -| **Buy with payment** | Finalize a transfer AND pay the seller in a single transaction (atomic swap). | All of these need the signer unlocked and a synced node. @@ -362,39 +375,38 @@ header checkbox to select all). A batch action bar appears at the bottom: - **Redeem Selected** — bulk sweep losing-bid coins from selected names. - **Finalize Selected** — bulk finalize outgoing TRANSFERs whose lockup has expired. +- **Transfer Selected** — transfer all selected names to one recipient + address, entered in the action bar. -Each batch action opens a **confirmation modal** showing the count, estimated -fee, and a collapsible list of the selected names. Cancel closes without +Each batch action opens a **confirmation modal** showing the count, the amount +the transaction moves (every output except change — a batch reveal or redeem +carries one per bid), the estimated fee, and a collapsible list of the +selected names. Cancel closes without broadcasting; Confirm signs + broadcasts the draft in one step. -Batch operations use hsd's `createbatch` RPC, which handles consensus limits -automatically (chunking to stay under block-size limits). - -**Note:** The description above is inaccurate and kept only until the next -manual pass rewrites this section. In reality, batch operations are built -client-side as a single transaction (there is no `createbatch` RPC in hsd). -The client enforces a conservative `MAX_BATCH_SIZE=100` names per batch, -well below hsd's per-transaction covenant limits (300 OPENS, 600 UPDATES, -600 RENEWALS). Per-block limits are identical to per-tx limits, so a -100-item batch will never be rejected on covenant-count grounds. Chunking -is not implemented — each batch is one atomic transaction with one txid. +A batch is built in the wallet as one transaction with one txid, so it either +all lands or none of it does. There is no chunking and no `createbatch` RPC in +hsd. The wallet caps a batch at 100 names, well under hsd's per-transaction +covenant limits (300 OPENs, 600 UPDATEs, 600 RENEWs), and per-block limits are +the same as per-transaction ones — so a full batch is never refused for +carrying too many covenants. ### Paid name swaps -To sell a name for HNS (**Sell with payment** flow): -1. Open the name in **Manage** → **Sell with payment** section. -2. Enter the buyer's address, your price (HNS), and confirm. This transfers - the name to the buyer with a lockup period recorded as a saved offer. -3. Wait for the buyer to broadcast their finalize-with-payment tx. -4. Once the buyer's tx confirms, the app verifies it (checks the payment - output matches your offer) and marks the offer paid — HNS lands in your - wallet atomically. - -To buy a name: -1. Wait for the seller to transfer the name to your address (name shows TRANSFER state). -2. Click **Buy with payment** → enter seller's address + amount. -3. Review the draft → sign → broadcast. -4. The name is finalized and the seller is paid in the same transaction. +Not available. The wallet once offered "Sell with payment" and "Buy with +payment"; both were withdrawn on 2026-09-21 because the shape they implemented +could not do what the names promised. A transfer's coin stays at the seller's +address, so only the seller can finalize — which left "Buy with payment" +pressable only by the party with nobody to pay — and nothing about the +transaction was atomic, so "one transaction" meant one wallet funding both +halves of its own trade. + +Selling a name for HNS therefore means transferring it and being paid +separately, with the trust that implies. If you recorded an offer before the +buttons were withdrawn, its claim panel still appears and still works. + +`docs/specs/2026-09-21-paid-name-swaps.md` has the consensus rules behind this +and what a working implementation would need. --- @@ -470,7 +482,7 @@ All node settings live under **Settings → Connections**. | Field | Default | Notes | |-------|---------|-------| -| **Chain source** | **Local full node** | How the wallet reads and sends: **Local full node** (hsd on this device, full indexes), **SPV** (lightweight, explorer-dependent, read-only), **Remote node** (user-provided hsd RPC), or **Explorer only** (read-only). | +| **Chain source** | **Local full node** | Which node the wallet sends through: **Local full node** (hsd on this device, full indexes), **SPV — lightweight, read-only** (headers only, explorer for data), **Remote node** (someone else's hsd RPC), or **Read-only (never send)**. Reads are not routed by this selector: they come from the node whenever it is synced and on your wallet's network, and from the explorer otherwise. | | **Node RPC URL** | `http://127.0.0.1:12037` | When chain source is Local full node or Remote node. Mainnet 12037, testnet 13037, regtest 14037. | | **Node RPC API key** | (empty) | For remote nodes: match the remote hsd's `--api-key`. Ignored for local nodes. | | **Allow sending via remote node** | **off** | When chain source is Remote node, enable this to broadcast signed transactions to the remote node. Off by default for safety. | @@ -502,7 +514,8 @@ data fresh without the app being open. ### SPV mode (lightweight) -The **Node mode** dropdown (Settings → Connections) lets you choose between: +The **Chain source** selector (Settings → Connections) offers SPV beside the +full node: - **Full node** (default): hsd runs with `--index-address --index-tx`. Requires ~15GB disk space and initial sync time. Supports sending and full local data. @@ -518,12 +531,12 @@ When SPV mode is active: **To enable SPV mode:** 1. Go to **Settings → Connections** -2. Change **Node mode** from "Full node" to "SPV" +2. Change **Chain source** from "Local full node" to "SPV — lightweight, read-only" 3. **Save settings** — hsd restarts with `--spv` flag 4. Data reads now come from the explorer; sending is blocked **To switch back to full node:** -1. Change **Node mode** back to "Full node" +1. Change **Chain source** back to "Local full node" 2. **Save settings** — hsd restarts with `--index-address --index-tx` 3. Full sync begins (may take time if the chain has advanced significantly) diff --git a/docs/adr/0001-per-profile-node-configuration.md b/docs/adr/0001-per-profile-node-configuration.md index 686f1e3a..7ef5f939 100644 --- a/docs/adr/0001-per-profile-node-configuration.md +++ b/docs/adr/0001-per-profile-node-configuration.md @@ -4,7 +4,7 @@ When a wallet profile is created for a specific network (mainnet, testnet, regte We will store per-profile node configuration in a new `profile_settings` table (key/value pairs per profile_id), with explicit keys `node_rpc_url`, `node_rpc_api_key`, and `chain_source`. Resolution order is: profile override → global setting → built-in default. This keeps the global settings as a fallback and avoids breaking existing code that reads from global settings only. -The resolution function `effective_node_config_for_profile(profile_id)` is called at the start of any operation that needs to know which node to use (sync, read, broadcast, chain scan). It returns `(node_rpc_url, node_rpc_api_key, chain_source)` or an error if the profile does not exist. The function is network-aware: it does not override the profile's network, only the node endpoint. A per-profile override that points to an unreachable or mismatched-network node is treated as a configuration error (not a fallback to global), consistent with the guard in `read.rs:151` and the principle stated in ADR-002 (network-derived behaviour): explicit choices are not silently abandoned. +The resolution function `effective_node_config_for_profile(profile_id)` is called at the start of any operation that needs to know which node to use (sync, read, broadcast, chain scan). It returns `(node_rpc_url, node_rpc_api_key, chain_source)` or an error if the profile does not exist. The function is network-aware: it does not override the profile's network, only the node endpoint. A per-profile override that points to an unreachable or mismatched-network node is treated as a configuration error (not a fallback to global), consistent with the guard in `commands/node_readiness.rs` (`node_tip_height_if_synced_with_client`) and the principle stated in ADR-002 (network-derived behaviour): explicit choices are not silently abandoned. The frontend will offer a "Configure node for this profile" button in Settings when viewing a profile, and a modal to set/clear the per-profile override. The global Settings → Connections remains unchanged for users who do not need per-profile overrides. diff --git a/docs/specs/2026-09-11-remote-node-connection-and-broadcast-guard.md b/docs/specs/2026-09-11-remote-node-connection-and-broadcast-guard.md index 249227ae..170830ae 100644 --- a/docs/specs/2026-09-11-remote-node-connection-and-broadcast-guard.md +++ b/docs/specs/2026-09-11-remote-node-connection-and-broadcast-guard.md @@ -1,6 +1,6 @@ # Remote-node connection & broadcast guard -**Status:** implemented on `feat/spv-broadcast-guard-and-remote-node-onboarding`. +**Status:** implemented (#52); the node gate moved to `commands/node_readiness.rs` in the #51–#62 review follow-ups. **CHANGELOG:** `## [Unreleased]` → "Remote-node onboarding" (Added), "SPV mode can no longer broadcast" and "`allow_remote_broadcast` is now enforced" (Fixed). @@ -30,8 +30,8 @@ with the wallet's once a wallet exists and warns on a mismatch. - **Network mismatch**: the node's `chain` and the active wallet profile's `network` name different Handshake networks after `main` ↔ `mainnet` normalization. -- **Read gate**: `commands::read::node_tip_height_if_synced_with_client` — - decides whether the local/remote node is authoritative for reads. +- **Read gate**: `commands::node_readiness::node_tip_height_if_synced_with_client` + — decides whether the local/remote node is authoritative for reads. - **Broadcast boundary**: `commands::tx::broadcast_tx_draft` and `ChainSource::can_broadcast()` in `noncustodial::rpc`. @@ -85,9 +85,34 @@ reported as "syncing", not "synced". (The read gate uses `true` for the same call because a regtest miner never reports progress — the two call sites are deliberately different and each says why.) -**R7 — Network comparison in the probe.** `check_node_connection` reads +**R6b — One rule decides "synced", and the chain tip is it.** A node is synced +once the blocks it has applied have caught up to the best header it knows +about. `verificationprogress` only corroborates that: it can plateau just below +1.0 — around 0.9997 on regtest — so a node sitting at the tip would otherwise +never qualify. To keep a node reporting `blocks == headers` while barely +verified from passing, progress (when reported) must also clear a loose 0.999 +floor. + +Two fallbacks: with no header height reported (older builds), the rule falls +back to `verificationprogress >= 0.9999`; with neither reported, the answer is +the caller's `assume_when_unknown`, which is what R6 above is about. + +The same function answers for the read gate, the write gate, the node-status +panel and "Test connection", so the label a user reads cannot disagree with +what reads actually do. The status payload reports the verdict rather than +letting the frontend re-derive it. +*Enforced:* `noncustodial/rpc.rs::chain_synced`, surfaced as `synced` by +`commands/node.rs::node_status`. +*Pinned:* `rpc::tests` (the `chain_synced` cases), +`node-status.test.tsx` (what each verdict renders). +Documented in `docs/NODE_SETUP.md` ("When the wallet calls a node 'synced'"). + +**R7 — Network comparison in the probe.** `check_node_connection` takes the +network to compare against from its caller when one is supplied — which is how +onboarding compares before any profile exists — and otherwise reads `db::queries::get_active_profile_network(&conn)` (a DB error is returned to -the UI, not swallowed) and passes it to `check_node_connection_with_client`, +the UI, not swallowed). Either way it passes the answer to +`check_node_connection_with_client`, which sets `network_matches = noncustodial::network::network_check(expected, info.chain)`. Semantics: `Some(false)` = mismatch; `Some(true)` = match; `None` = nothing to compare (no active profile, or node reports no `chain`). @@ -152,9 +177,15 @@ frontend fixture that builds a `NodeConnectionCheck` includes `networkMatches`. capability only. Exception: with `node_mode = spv` the node is never authoritative for reads (`is_node_ready_for_local_reads` returns false), so SPV reads always come from the explorer. -- **The read gate does not fail closed on a DB error.** A failure to load the - profile network degrades to "no network to compare" (routing decision, not - a security boundary). The probe (R7) does fail loudly. +- **The read gate does not fail closed on a DB error.** In + `is_node_ready_for_local_reads` a failure to load the profile network + degrades to "no network to compare" (routing decision, not a security + boundary). Everything that *writes* on the strength of the answer does fail + closed: the background sync (`commands/sync.rs`), the chain scanner + (`commands/chain_scan.rs`), the watched-name daemon + (`daemon/watched_names.rs`) and `node_tip_height_if_synced` all treat a + network they cannot read as a node they cannot vouch for. The probe (R7) + fails loudly. ## 5. Known gaps @@ -172,7 +203,7 @@ frontend fixture that builds a `NodeConnectionCheck` includes `networkMatches`. ## 6. Pointers - Backend: `src-tauri/src/commands/node.rs` (probe, `NodeConnectionCheck`), - `src-tauri/src/commands/read.rs` (read gate), + `src-tauri/src/commands/node_readiness.rs` (read gate), `src-tauri/src/commands/tx.rs` (`broadcast_tx_draft`), `src-tauri/src/noncustodial/rpc.rs` (`ChainSource`, `can_broadcast`, `remote_broadcast_allowed`), `src-tauri/src/noncustodial/network.rs` diff --git a/docs/specs/2026-09-14-network-derived-behaviour.md b/docs/specs/2026-09-14-network-derived-behaviour.md index f07d14eb..b8e579dd 100644 --- a/docs/specs/2026-09-14-network-derived-behaviour.md +++ b/docs/specs/2026-09-14-network-derived-behaviour.md @@ -40,8 +40,8 @@ fixups, and cannot be silently pointed at the wrong chain. ### Chain identity **N1 — One gate, no opt-out.** Every "is this node authoritative?" decision -compares the node chain to the profile network. `read.rs` exposes no helper that -skips the comparison: the `State`-based `node_tip_height_if_synced` resolves the +compares the node chain to the profile network. `commands/node_readiness.rs` +exposes no helper that skips the comparison: the `State`-based `node_tip_height_if_synced` resolves the active profile itself, and the settings-based `node_tip_height_if_synced_from_settings_with_network` and `node_ready_from_settings` take the expected network as a required argument. @@ -61,7 +61,9 @@ before any write and returns an error on a positive mismatch. Nothing reaches `sync_cursors`, `wallet_profiles.last_synced_height` or `tracked_name_states`. This requirement exists because `sync_cursors` is the tip `noncustodial/send.rs::load_spendable_coins` reads to decide coinbase maturity: a -foreign height there silently corrupts N6. +foreign height there silently corrupts N6. Pinned by +`tests/tx_lifecycle_tests.rs::sync_wallet_state_refuses_a_node_on_another_chain`, +which also asserts the coin route is never asked and `sync_cursors` stays empty. **N4 — Broadcasting to a foreign chain is refused.** `commands/tx.rs::broadcast_network_guard_with_client` probes the node and returns @@ -157,16 +159,17 @@ permanently in "expiring soon". `derive_auction_task_state` and **N13 — A stored height is only aged by wall clock where blocks follow one.** `Network::has_wall_clock_block_timing` is true for main and testnet only. -`commands/read.rs::estimate_persisted_height` ages its candidates by +`commands/node_readiness.rs::estimate_persisted_height` ages its candidates by `elapsed_seconds / 600` on those networks and by zero elsewhere. Regtest and simnet mine on demand, so the old arithmetic invented six blocks for every idle hour and every renewal countdown drifted. A stale height is reported as stale. Pinned by `test_has_wall_clock_block_timing_all_variants`. -**N14 — Mainnet-only explorer links appear only on mainnet.** `NameInfoModal` -and `NameActionsModal` gate their "View on explorer" link on +**N14 — Mainnet-only explorer links appear only on mainnet.** +`NameActionsModal` gates its "View on explorer" link on `profile.network === "mainnet"`, as `TxInfoModal`, `BlockInfoModal`, -`ReceiveAddressList` and `WalletView` already did. Shakeshift indexes no other +`ReceiveAddressList` and `WalletView` already did. (`NameInfoModal` carried the +same gate and was folded into `NameActionsModal` in #57.) Shakeshift indexes no other chain, so the link 404s elsewhere. **N15 — Starting a node refuses rather than guessing mainnet.** @@ -178,6 +181,50 @@ form stays for callers that only *label* a network (status payloads, the mainnet-only Namebase paths). Pinned by `the_optional_form_reports_no_profile_as_none`. +**N16 — Each network gets its own data-dir root.** hsd isolates non-mainnet +chains *inside* a prefix (`/regtest`) while mainnet writes +`blocks/chain/tree` at the prefix root, so one shared prefix let a mainnet +chain at the root sit beside a regtest subdir — the overlap that allowed a +mainnet chain to drive a regtest wallet. Mainnet keeps the base unchanged, so +existing mainnet data is never moved; every other network gets `/` +as its own root. This applies to a user-configured `hsd_prefix` too, not only +the default. The wallet passes hsd the un-scoped base as `--prefix` and lets +`--network` create that subdir, so hsd's chain root and the wallet's data dir +are the same directory rather than nesting twice. +*Enforced:* `commands/node.rs::network_scoped_data_dir`, +`commands/node.rs::resolve_data_dir_for_network`. +*Pinned:* `node_status_tests` (the `network_scoped_data_dir` cases). +Documented in `docs/NODE_SETUP.md` ("Node (hsd) datadirs and default RPC ports"). + +**N17 — An existing chain is relocated once, and never mainnet's.** A wallet +upgrading from the shared-prefix layout has this network's chain in the legacy +place. Starting a node moves it into the scoped root. The move is the only +place the wallet relocates a user's chain files, so it is narrow: it refuses +outright on mainnet, does nothing when the scoped root already holds a chain +("do nothing if we already have"), and otherwise either adopts the legacy +subdirectory or creates an empty scoped root. It is idempotent, so a repeated +start is a no-op. +*Enforced:* `commands/node.rs::plan_network_migration` (the pure decision) and +`migrate_network_prefix` (the move). +*Pinned:* `node_status_tests::plan_network_migration_never_touches_mainnet` and +its neighbours. + +**N18 — A node that failed to start says why.** When the RPC does not answer, +the wallet reads the hsd log and, if it records a fatal startup line, shows the +last eight lines with a reason. An index mismatch — hsd cannot enable an index +on an existing chain — gets specific guidance and offers the one-click re-sync; +anything else reports as a failed start. Routine peer and socket errors during +sync are explicitly not fatal, because hsd logs the word "Error" throughout a +healthy sync. The matching is deliberately broad rather than precise: this runs +only when the node is already unreachable, so over-reporting relabels "still +starting" on a node that is down either way, while under-reporting leaves a +broken node silent. +*Enforced:* `commands/node.rs::is_fatal_startup_line`, `node_start_error`. +*Pinned:* `node_status_tests::routine_sync_noise_is_not_a_startup_failure`, +`the_known_fatal_shapes_are_recognised`, +`a_data_dir_path_that_merely_contains_bind_is_not_a_failure`, +`a_broad_matcher_is_documented_rather_than_quietly_wrong`. + ## 4. Explicitly not enforced - **The app does not verify the node is honest about its chain.** Every guard @@ -190,11 +237,12 @@ mainnet-only Namebase paths). Pinned by - **The network of an existing profile cannot be changed.** Not a guard, an absence: no command and no `UPDATE` writes the column. Changing network means creating another profile. -- **`NodeRpcClient::from_settings` still falls back to the mainnet port.** Its - 42 construction sites make threading a network through it a disproportionate - change, and the fallback only applies when `node_rpc_url` is absent — which - migration `009` makes impossible in practice. The setting itself is what N11 - keeps correct. +- **`NodeRpcClient::from_settings` still falls back to the mainnet port.** The + fallback only applies when `node_rpc_url` is absent — which migration `009` + makes impossible in practice — and the setting itself is what N11 keeps + correct. Its remaining construction sites (13 at the time of writing, five + of them inside `rpc.rs`) are the no-active-profile paths; every profile + path resolves through `NodeRpcClient::for_profile` (ADR-001). - **The explorer read fallback is network-gated.** ~~Previously a gap.~~ `providers::explorer_client_from_settings` now takes a `Network` and returns `Option`. Resolution order: explicit @@ -202,7 +250,11 @@ mainnet-only Namebase paths). Pinned by (mainnet only) > `None`. On testnet/regtest/simnet with no explicit URL the factory returns `None`, and every read/sync call site threads that through as either a candid "explorer unavailable" error or a degraded empty result - — never a silent mainnet query. + — never a silent mainnet query. One explicit URL does not count as a + choice: the mainnet explorer an early migration seeded into every database. + Migration `032` removes exactly that value, and the factory refuses it off + mainnet in case a database reaches it before the migration has run + (`provider_hnsfans_tests`, the seeded-URL cases). - **Notification lead defaults are not scaled per network.** `reveal_lead_blocks` (144) and `DEFAULT_BIDDING_SOON_LEAD_BLOCKS` (144) exceed the entire reveal and bidding windows on test chains, so those notices are on @@ -242,7 +294,7 @@ mainnet-only Namebase paths). Pinned by ## 6. Pointers -- Gates: `src-tauri/src/commands/read.rs`, `commands/tx.rs`, +- Gates: `src-tauri/src/commands/node_readiness.rs`, `commands/tx.rs`, `commands/sync.rs`, `commands/chain_scan.rs`, `daemon/watched_names.rs`. - Network parameters: `src-tauri/src/noncustodial/network.rs`. - Maturity filter: `src-tauri/src/noncustodial/send.rs`. diff --git a/docs/specs/2026-09-15-batch-name-operations.md b/docs/specs/2026-09-15-batch-name-operations.md new file mode 100644 index 00000000..d9f6df96 --- /dev/null +++ b/docs/specs/2026-09-15-batch-name-operations.md @@ -0,0 +1,146 @@ +# Batch name operations + +Status: implemented. Written after the fact, when a review found the feature +had shipped across backend, frontend and user docs with no spec. + +## 1. Summary + +Acting on one name at a time is the wrong unit of work for a portfolio: a +hundred names come up for renewal together, and a wallet that bid on twenty +names has twenty reveals to make in the same window. The user selects names in +the Owned Names table, presses one button, sees a confirmation naming the count +and the fee, and signs once. The result is a single transaction with a single +txid — it all lands or none of it does. + +## 2. Terms + +- **Batch** — one transaction carrying the same covenant for several names. + Not a queue and not a series: there is one draft, one signature and one txid. +- **Batch size** — how many names one batch carries. Capped at + `MAX_BATCH_SIZE`. +- **Per-name prefetch** — the `(name, name_hash, owner_coin, on_chain_state)` + tuple the command resolves for every name before it builds anything, so a + name that cannot participate stops the batch before any write. +- **Inner builder** — the `build_batch_*_draft_inner` function: the pure half, + taking a `Connection` and the prefetch rather than a Tauri `State`. + +## 3. Requirements + +**B1 — Six covenants can be batched.** `bid`, `renew`, `transfer`, `reveal`, +`redeem` and `finalize`. Nothing else is offered in bulk. +*Enforced:* `commands/names.rs::build_batch_{bid,renew,transfer,reveal,redeem,finalize}_draft`. +*Pinned:* the happy path of each — `build_batch_bid_draft_tests::batch_bid_happy_path_persists_summary_commitments_and_draft`, +`build_batch_renew_draft_tests::batch_renew_happy_path_persists_draft`, +`build_batch_transfer_draft_tests::batch_transfer_happy_path_persists_draft`, +`build_batch_reveal_draft_tests::batch_reveal_happy_path_persists_draft`, +`build_batch_redeem_draft_tests::batch_redeem_happy_path_persists_draft`, +`build_batch_finalize_draft_tests::batch_finalize_happy_path_persists_draft`. + +**B2 — A batch is one transaction, not a chunked series.** There is no +`createbatch` RPC in hsd and no chunking here: every batch builder calls +`actions::build_batch_plan` once and persists one draft. +*Enforced:* `commands/names.rs` (each `*_inner` → `build_batch_plan` → +`persist_with_conn`). +*Pinned:* the happy-path tests above assert a single persisted draft. + +**B3 — At most 100 names per batch.** `MAX_BATCH_SIZE = 100`, refused with the +count and the limit in the message. The number is deliberately well under +hsd's per-transaction covenant limits (300 OPENs, 600 UPDATEs, 600 RENEWs); +per-block limits are the same as per-transaction ones, so a full batch is never +refused for carrying too many covenants. +*Enforced:* `commands/names.rs::MAX_BATCH_SIZE` and the length check in each +batch command. + +**B4 — One bad name aborts the whole batch, before any write.** Every name's +owner coin and on-chain state are resolved up front, and a shared destination +is decoded up front, so a name in the wrong phase or an unparseable address +fails while nothing has been persisted or reserved. A partially applied batch +would be worse than none: the user would have to work out which half went. +*Enforced:* the per-name prefetch loop and the early `address::decode` in each +batch command. +*Pinned:* `build_batch_bid_draft_tests::batch_bid_one_bad_phase_aborts_entire_batch`, +`build_batch_finalize_draft_tests::batch_finalize_rejects_non_transfer_coin`, +`build_batch_reveal_draft_tests::batch_reveal_bad_nonce_length_errors`. + +**B5 — Coin reservation and draft persistence are atomic.** The inner builder +requires the caller to hold the DB mutex for its whole duration, so the coins a +batch reserves and the draft that claims them are written under one guard. A +second batch cannot select a coin the first has taken. +*Enforced:* each `build_batch_*_draft_inner` (documented on the function), with +the wrapper taking `state.db.lock()` immediately before the call. +*Pinned:* `build_batch_transfer_draft_tests::batch_transfer_happy_path_persists_draft` +asserts the reservation count. + +**B6 — A batch transfer names one recipient, and the value stays put.** The +recipient is decoded once and written into every name's TRANSFER covenant; the +output value stays at each name's current owner address until FINALIZE moves +it, as consensus requires. +*Enforced:* `commands/names.rs::build_batch_transfer_draft_inner`. +*Pinned:* `build_batch_transfer_draft_tests::batch_transfer_uses_owner_address_for_output`. + +**B7 — A batch of one is still a batch.** Selecting a single name takes the +same path rather than falling back to the singular command, so the two cannot +drift. +*Pinned:* `batch_transfer_single_name`, `batch_renew_single_name`, +`batch_reveal_single_name`, `batch_redeem_single_name`, +`batch_finalize_single_name`. + +**B8 — An empty selection is refused where it is meaningless.** `transfer` and +`finalize` reject an empty name list outright. `bid`, `renew`, `reveal` and +`redeem` build a zero-input draft instead, which is what their callers expect +when a filter matches nothing. +*Pinned:* `batch_transfer_empty_errors`, `batch_finalize_empty_errors`, +`batch_bid_empty_specs_persists_zero_output_draft`, +`batch_renew_empty_persists_zero_input_draft`, +`batch_reveal_empty_persists_zero_input_draft`, +`batch_redeem_empty_persists_zero_input_draft`. + +**B9 — The confirmation says what will be signed.** `BatchConfirmModal` shows +the count, the estimated fee and a collapsible list of the selected names. +Cancel closes without broadcasting; Confirm signs and broadcasts in one step. +The amount it reports sums every output except change (R13d of the +[multiple-bids spec](./2026-09-20-multiple-bids-per-name.md)), which matters +here because a batch reveal or redeem carries one output per bid. +*Enforced:* `src/components/BatchConfirmModal.tsx`, driven from +`src/components/WalletView.tsx`. +*Pinned:* `src/components/__tests__/batch-transfer.test.tsx`. + +**B10 — A batched action still counts as spending the name.** The draft a +batch builder persists records itself as `batch-`, and the owner-spend +check strips that prefix — see R11d of the +[multiple-bids spec](./2026-09-20-multiple-bids-per-name.md). + +## 4. Explicitly not enforced + +- **No chunking.** A selection over `MAX_BATCH_SIZE` is refused, not split. + The user reduces the selection. +- **No per-name result.** A batch is one transaction, so there is no notion of + "eight succeeded, two failed" — the node accepts it or it does not. +- **No cross-covenant batching.** Renewing some names and revealing others in + one transaction is possible on-chain but not offered; each button carries one + covenant. +- **No fee negotiation per name.** One fee rate applies to the whole batch. + +## 5. Known gaps + +- **The 100-name cap is a round number, not a measured limit.** It is well + under the consensus covenant limits, but the real ceiling is transaction + size, which depends on the inputs coin selection picks. A batch of 100 names + with many small inputs could still be large. +- **`build_batch_*_draft` commands carry `coverage(off)`.** They are IO shells, + which the standard allows, but it means the length and prefetch checks are + covered only through their inner builders and the frontend tests. + +## 6. Pointers + +- Backend: `src-tauri/src/commands/names.rs` (`MAX_BATCH_SIZE`, the six + `build_batch_*_draft` commands and their `*_inner` halves), + `src-tauri/src/noncustodial/actions.rs` (`build_batch_plan`). +- Frontend: `src/components/BatchConfirmModal.tsx`, + `src/components/WalletView.tsx` (selection + action bar), + `src/components/BatchBidModal.tsx`. +- Tests: `src-tauri/src/tests/build_batch_{bid,renew,transfer,reveal,redeem,finalize}_draft_tests.rs`, + `src/components/__tests__/batch-transfer.test.tsx`. +- Docs: `docs/USER_MANUAL.md` ("Batch operations"), `docs/QA_WORKFLOWS.md` + (section I). +- CHANGELOG: the batch-transfer entry under `## [Unreleased]`. diff --git a/docs/specs/2026-09-15-per-profile-node-banner-and-preflight.md b/docs/specs/2026-09-15-per-profile-node-banner-and-preflight.md index 5e7b4b3b..3c293a16 100644 --- a/docs/specs/2026-09-15-per-profile-node-banner-and-preflight.md +++ b/docs/specs/2026-09-15-per-profile-node-banner-and-preflight.md @@ -8,9 +8,22 @@ on top of [ADR-001 Per-profile node configuration](../adr/0001-per-profile-node- the ADR defines *what is stored and how it resolves*, this spec defines *what the user sees and can do*. -The prototype at `prototypes/per-profile-node-banner/index.html` answered these -questions and is no longer needed — the verdicts below are the durable output -and will be implemented step by step in the real app. +A throwaway prototype answered these questions and was deleted with them; the +verdicts below are its durable output, implemented step by step in the real app. + +Progress against the plan at the bottom of this file: + +| Step | State | +|------|-------| +| 1. Schema + resolver | Done. `profile_settings` (migration 027), `effective_node_config_for_profile`. | +| 2. Migrate call sites | Done. Every node client resolves per profile; realign runs only on fallback resolution; an unresolvable profile config is an error, never a fallback to global. | +| 3. Preflight command | Not started. No `profile_preflight` exists. | +| 4. Banner UI | Not started. | +| 5. Preflight cache | Not started. | +| 6. Override modal | Not started — so nothing writes `profile_settings` outside tests, and the override is not yet reachable by a user. | +| 7. Write-path fresh preflight | Not started. | +| 8. Per-slot read/write overrides | Not started. The table has no slot column, and the resolver returns one tuple. | +| 9. E2E matrix test | Not started. | ## Vocabulary (from CONTEXT.md) @@ -135,7 +148,7 @@ decisions surfaced by the prototype). - **V-S2 — global write is refused when `allow_remote_broadcast != "true"`.** A remote-node write source is disabled unless the user explicitly opts in, - consistent with the R7/R8 rules in the remote-node spec. + consistent with R11 of the remote-node spec. ## Non-goals diff --git a/docs/specs/2026-09-20-multiple-bids-per-name.md b/docs/specs/2026-09-20-multiple-bids-per-name.md index 375a6eb2..cc0cda38 100644 --- a/docs/specs/2026-09-20-multiple-bids-per-name.md +++ b/docs/specs/2026-09-20-multiple-bids-per-name.md @@ -109,8 +109,9 @@ each bid lands on its own rotated address. `set_bid_reveal_txid` is keyed by `blind_hex`. Stamping by name marked every commitment revealed, which silenced the reveal-deadline warning for exactly the bids still at risk. The batch path stamps too. -*Enforced:* `db/queries.rs::set_bid_reveal_txid`, `commands/names.rs`, -`noncustodial/actions.rs` (`build_batch_plan`). +*Enforced:* `db/queries.rs::set_bid_reveal_txid`, called from both reveal +builders in `commands/names.rs` (`build_reveal_draft_inner` and the batch +reveal command) — the plan in `noncustodial/actions.rs` carries no txid. *Pinned:* `names_cmd_tests::build_reveal_draft_persists_reveal_txid_on_its_commitment`, `deadlines_cmd_tests::revealed_bid_is_excluded_even_if_the_window_would_be_imminent`. @@ -174,21 +175,60 @@ false. `names::tests::ownership_actions_stay_available_once_registered`, `sign_name_message_tests::rejects_a_name_whose_owner_coin_is_still_a_reveal`. -**R11b — The two transfer capabilities answer for the transfer.** Update is -refused while a transfer is pending: hsd lets a TRANSFER coin go to UPDATE, -RENEW, FINALIZE or REVOKE, and that UPDATE branch *is* the cancel, so a button -labelled "edit your DNS records" was a way to lose a transfer in flight. -Cancel transfer requires a transfer to cancel — the condition `can_finalize` -has always carried — instead of building a no-op UPDATE that costs a fee. +**R11b — Every capability answers for a transfer in flight.** hsd lets a +TRANSFER coin go to UPDATE, RENEW, FINALIZE or REVOKE, and consensus decides +which of those a button may offer. + +- **Update** is refused: its UPDATE branch *is* the cancel, so a button + labelled "edit your DNS records" was a way to lose a transfer in flight. +- **Renew** is refused for the same reason. hsd's RENEW handler runs + `ns.setTransfer(0)` exactly as UPDATE does, so "extend my registration" + ended a transfer and said nothing about transfers. +- **Transfer** is refused: a TRANSFER coin may become an UPDATE, RENEW, + FINALIZE or REVOKE and nothing else, so a second one is a transaction the + node rejects. +- **Revoke** stays offered. Consensus allows it from a TRANSFER coin, and + destroying the name is exactly what that button says it does. +- **Cancel transfer** requires a transfer to cancel — the condition + `can_finalize` has always carried — instead of building a no-op UPDATE that + costs a fee. + *Enforced:* `commands/names.rs::build_name_action_capabilities`. *Pinned:* `names::tests::update_is_refused_while_a_transfer_is_pending`, -`names::tests::cancel_transfer_is_refused_when_no_transfer_is_pending`. +`names::tests::cancel_transfer_is_refused_when_no_transfer_is_pending`, +`names::tests::renew_is_refused_while_a_transfer_is_pending`, +`names::tests::transfer_is_refused_while_a_transfer_is_already_pending`. + +**R11c — Finalize waits out the transfer lockup.** hsd refuses a FINALIZE +until `transfer + transferLockup` blocks have passed (`bad-finalize-maturity`), +so offering it the moment a transfer is mined sends the user at a transaction +the node throws away — two days on mainnet, ten blocks on regtest. The gate +compares the transfer's height against the tip and the reason counts the +blocks left rather than only saying no. It prefers the live tip, fetched once +per call, and falls back to the persisted estimate when no synced node answers; +with either height unknown the action stays offered, because refusing one the +node would accept is its own kind of wrong. +*Enforced:* `commands/names.rs::build_name_action_capabilities`, +`commands/names.rs::evaluate_name_action_capabilities` (live tip). +*Pinned:* `names::tests::finalize_waits_out_the_transfer_lockup`, +`names::tests::finalize_is_not_blocked_when_the_lockup_is_unknown`, +`names::tests::a_live_tip_replaces_the_persisted_estimate`. + +**R11d — A batched owner spend is still an owner spend.** The draft a batch +builder persists records itself as `batch-`, so an owner-spend check +matching only the singular action names read a batched transfer as no transfer +at all — collapsing ownership the moment one went out. The match strips a +`batch-` prefix, which leaves `batch-bid` and `batch-redeem` correctly saying +nothing about ownership. +*Enforced:* `commands/names.rs::find_name_action_context`. +*Pinned:* `names_action_context_tests::find_name_action_context_recognises_a_batched_owner_spend`. **R12 — A name whose auction lapsed can be opened again.** Only an *unconfirmed* OPEN coin counts as a pending OPEN. The OPEN output is a zero-value marker nothing ever spends, so treating "we hold one" as "one is pending" made every name this wallet had ever opened permanently un-openable. -*Enforced:* `commands/names.rs::has_pending_open_coin` → +*Enforced:* `commands/names.rs::find_name_action_context` (its +`has_pending_open_coin` value) → `db/queries.rs::has_unconfirmed_covenant_utxo_by_name_hash`. *Pinned:* `names_action_context_tests` (pending-open cases), `build_open_draft_tests`, `live_node_it::live_double_open_and_double_bid_guarded`. @@ -196,15 +236,58 @@ pending" made every name this wallet had ever opened permanently un-openable. ### Saying what is happening **R13 — An action sent but not yet mined says so, everywhere it appears.** The -name modal, the auctions list and the guided panel all read the same -`pendingBroadcastAction` and render "waiting for a block" naming the action, -rather than the unchanged phase. +name modal, the auctions list, the guided panel and the Owned Names table's +State column all read the same `pendingBroadcastAction` and render "waiting for +a block" naming the action, rather than the unchanged phase. The State column +was the last surface that did not, so a row read "Owned" while the modal it +opened read "Redeem · waiting for a block". *Enforced:* `db/queries.rs::pending_broadcast_action_for_name`, -`src/lib/auction.ts` (`pendingBroadcastText`, `pendingBroadcastBadge`). +`src/lib/auction.ts` (`pendingBroadcastText`, `pendingBroadcastBadge`), +`src/components/WalletView.tsx` (State column). *Pinned:* `auction.test.ts :: names the action that is waiting for a block`, +`wallet-view.test.tsx :: defers to what is in flight, like the modal it opens`, `auction-positions.test.tsx :: a broadcasted open the node/explorer hasn't caught up to (waitingForBidding) shows Waiting for Bidding / View`, `name-acquisition.test.tsx :: says the OPEN is waiting to be mined, with no Open button`. +**R13b — A pending transfer is a task, not a phase that never arrives.** hsd +has six name states — OPENING, LOCKED, BIDDING, REVEAL, CLOSED, REVOKED — and +TRANSFER is not among them: a transfer leaves the state at CLOSED and shows +itself through `info.transfer`. A task derivation keyed on a `"TRANSFER"` phase +string therefore never fired, and every name being transferred fell through to +"no urgent action" — on a name whose one remaining action is to finalize it. +The task reads the transfer the node actually reports, and ranks behind the +renewal alarm but ahead of everything quiet: losing the name outranks +completing a transfer of it, and nothing else does. That includes R8: a name +mid-transfer that still holds losing reveals reports the transfer, not the +redeem, because the transfer is the task the user started and the name is on +its way out of the wallet, while the reveals are reclaimable at any time — +from the bids panel, the manual auction actions, or Redeem Selected. +*Enforced:* `commands/names.rs::derive_auction_task_state`. +*Pinned:* `auction_capabilities_tests::a_recorded_transfer_yields_transfer_pending_finalize`. + +**R13c — A reclaimed lockup is money again.** REDEEM is classified as +spendable, not name-bound. hsd draws the same line in +`Covenant::isNonspendable()`, which returns false for NONE, OPEN and REDEEM: a +redeemed coin spends like any other output. Without this, HNS reclaimed from +losing bids landed in `name_control`, so the balance card did not show it as +spendable and coin selection would not draw on it — the user had just paid a +fee to get it back and it stayed invisible. OPEN stays name-bound on purpose: +hsd would spend it too, but it is a zero-value marker, so counting it as liquid +would add an input and no value. No migration is needed; the sync upsert +rewrites `spend_class` on conflict. +*Enforced:* `noncustodial/sync.rs` (spend-class match). +*Pinned:* `sync::tests::redeemed_value_counts_as_liquid_balance`. + +**R13d — A confirm dialog counts every output the action carries.** A name +action can carry several: revealing a name bid on more than once emits one +REVEAL per bid, and redeeming reclaims one per losing reveal. Reporting +`outputs[0]` offered a live wallet a redeem of three reveals worth 28 HNS with +12 on the dialog — the one figure a user checks before signing, wrong on every +multi-bid action. The total sums every output except change, which the plan +already identifies by index. +*Enforced:* `commands/names.rs` (`send_total_doos` in the draft summary). +*Pinned:* `build_redeem_draft_tests::build_redeem_draft_totals_every_output_it_reclaims`. + **R14 — Our own unmined bid appears in the bids panel, counted apart.** A bid we broadcast but the chain has not indexed is appended to the list as `pending: true`, so placing a second bid does not look like it vanished. Only @@ -306,12 +389,6 @@ capability fields `nameIsRegistered` and `transferPending`. ## 5. Known gaps -- **A multi-reveal draft reports only its primary output in the send total.** - `sendTotalDoos` on a reveal covering several bids shows the first output's - value rather than the sum, so the confirm dialog understates the amount - moved (the fee and the transaction itself are correct). Accepted for now: - it is a display figure on a self-spend, and every output returns to the - wallet. - **`DataTable` still carries an `onRowClick` prop with no caller.** Kept because the guard in R18 is the thing worth keeping, and the next table that wants a row click should get the guarded version. @@ -360,6 +437,8 @@ capability fields `nameIsRegistered` and `transferPending`. `src/components/name-actions/__tests__/name-bids-panel.test.tsx`. **Docs** +- `docs/USER_MANUAL.md` — "Several bids on one name" (§8) and the amount line + of the batch confirmation (§10). - `docs/CODING_STANDARDS.md` — the no-native-`title` rule and the `lint:native-title` gate. - `CHANGELOG.md` — `[Unreleased] / Fixed`, the entries from "Update, diff --git a/docs/specs/2026-09-21-paid-name-swaps.md b/docs/specs/2026-09-21-paid-name-swaps.md index 69d230cc..c526bb94 100644 --- a/docs/specs/2026-09-21-paid-name-swaps.md +++ b/docs/specs/2026-09-21-paid-name-swaps.md @@ -86,3 +86,7 @@ and broadcasts. There is no separate "finalize with payment" command for them. - `src-tauri/src/noncustodial/actions.rs` — the `sighash::ALL` of W2. - `name-modal-sections.test.tsx :: offers no way to start a paid swap` — pins the withdrawal. +- User-facing copy that described the withdrawn flows, corrected to match: + `docs/USER_MANUAL.md` ("Paid name swaps", and the owned-name action table), + `README.md` (feature list), `docs/QA_WORKFLOWS.md` (section J). Any future + attempt has to update these three in the same PR as the code. diff --git a/docs/specs/README.md b/docs/specs/README.md index c1257220..c459cf05 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -18,7 +18,9 @@ Each spec has these sections, in this order: | Spec | Status | |------|--------| -| [2026-09-11 Remote-node connection & broadcast guard](./2026-09-11-remote-node-connection-and-broadcast-guard.md) | Implemented on `feat/spv-broadcast-guard-and-remote-node-onboarding` | -| [2026-09-14 Network-derived behaviour](./2026-09-14-network-derived-behaviour.md) | Implemented on `feat/batch-transfer` | -| [2026-09-20 Multiple bids per name](./2026-09-20-multiple-bids-per-name.md) | Implemented on `feat/batch-reveal-redeem-finalize-ui` | +| [2026-09-11 Remote-node connection & broadcast guard](./2026-09-11-remote-node-connection-and-broadcast-guard.md) | Implemented | +| [2026-09-14 Network-derived behaviour](./2026-09-14-network-derived-behaviour.md) | Implemented | +| [2026-09-15 Batch name operations](./2026-09-15-batch-name-operations.md) | Implemented; spec written after the fact | +| [2026-09-15 Per-profile node banner & preflight](./2026-09-15-per-profile-node-banner-and-preflight.md) | Steps 1-2 of 9 implemented — see the spec's own status table | +| [2026-09-20 Multiple bids per name](./2026-09-20-multiple-bids-per-name.md) | Implemented | | [2026-09-21 Paid name swaps](./2026-09-21-paid-name-swaps.md) | Not implemented — UI withdrawn, see spec | diff --git a/src-tauri/src/commands/active_profile.rs b/src-tauri/src/commands/active_profile.rs index 0a978f2e..01d31f64 100644 --- a/src-tauri/src/commands/active_profile.rs +++ b/src-tauri/src/commands/active_profile.rs @@ -4,6 +4,7 @@ //! `commands/namebase.rs`. use crate::db; +use crate::error::AppError; use crate::noncustodial::network::Network; use crate::AppState; @@ -48,3 +49,37 @@ pub(crate) fn active_profile_network_opt(state: &AppState) -> Option { let conn = state.db.lock().ok()?; active_profile_network_opt_from_conn(&conn) } + +/// The `Network` of one *named* profile, or an error when the profile is +/// missing or its stored string does not parse. For user-triggered commands +/// and read models, where guessing mainnet would compute the wrong answer +/// (a balance with the wrong coinbase maturity, a renewal window aged by a +/// wall clock regtest does not keep) and CODING_STANDARDS says a DB failure +/// is returned, not swallowed. +pub(crate) fn profile_network_from_conn( + conn: &rusqlite::Connection, + profile_id: &str, +) -> Result { + let profile = db::queries::get_wallet_profile(conn, profile_id)? + .ok_or_else(|| AppError::NotFound(format!("wallet profile {profile_id}")))?; + crate::noncustodial::derivation::network_from_profile(&profile.network) +} + +/// The `Network` of one *named* profile, or `None` when the profile is +/// missing, the DB errors, or the stored string does not parse. +/// +/// The three failures collapse into one answer on purpose: every caller of +/// this refuses to act rather than guess, and the guess would be mainnet. A +/// sync step that guessed would read another chain's explorer into this +/// profile's cache, which is the cross-network read the network guard exists +/// to prevent. Callers that only need a *label* should keep using +/// [`active_profile_network_from_conn`]. +pub(crate) fn profile_network_opt_from_conn( + conn: &rusqlite::Connection, + profile_id: &str, +) -> Option { + db::queries::get_wallet_profile(conn, profile_id) + .ok() + .flatten() + .and_then(|p| Network::from_str_opt(&p.network)) +} diff --git a/src-tauri/src/commands/chain_scan.rs b/src-tauri/src/commands/chain_scan.rs index 8eff551e..4497ed12 100644 --- a/src-tauri/src/commands/chain_scan.rs +++ b/src-tauri/src/commands/chain_scan.rs @@ -4,7 +4,9 @@ //! touching the HNSFans explorer. //! //! Design: -//! - Runs only while the node is synced (`node_ready_from_settings`). +//! - Runs only while the active profile's node is synced and on the profile's +//! network (`node_readiness::node_ready_from_profile`; the global settings +//! gate only when no profile is active). //! - Scoped to the active profile's network: both the cursor and the index are //! keyed by it, because a name hashes to the same value on every chain and a //! mainnet cursor height is meaningless against a regtest tip (see 028). @@ -110,7 +112,7 @@ pub async fn run_chain_scanner(db_path: String) { // Use per-profile probe if active profile exists; otherwise fall back to global. let tip = if let Some(profile_id) = active_profile_id.as_deref() { - match crate::commands::read::node_tip_height_if_synced_from_profile_with_network( + match crate::commands::node_readiness::node_tip_height_if_synced_from_profile_with_network( &db_path, profile_id, expected_network.as_deref(), @@ -124,7 +126,7 @@ pub async fn run_chain_scanner(db_path: String) { } } } else { - match crate::commands::read::node_tip_height_if_synced_from_settings_with_network( + match crate::commands::node_readiness::node_tip_height_if_synced_from_settings_with_network( &settings, expected_network.as_deref(), ) @@ -369,11 +371,11 @@ pub(crate) async fn scan_block( ], )?; } - // Match REVEALs to their BIDs by nameHash (within the same block or earlier - // blocks). A REVEAL output's value IS the true bid value; the BID output's - // value is the lockup (bid + mask). We update the FIRST matching BID that - // doesn't already have a reveal_txid — this is a best-effort heuristic; - // in practice each bidder has one BID per name per auction. + // Match REVEALs to their BIDs. A REVEAL output's value IS the true bid + // value; the BID output's value is the lockup (bid + mask). The pairing is + // exact, not a heuristic: each reveal names the outpoint of the bid it + // spends, which is the only thing that still works once a wallet holds + // several bids on one name. for reveal in &reveals { // Address the exact BID this reveal spends. The previous rule — "the // earliest bid not yet matched" — was indistinguishable from the truth @@ -526,3 +528,52 @@ pub fn read_indexed_bids( pub fn scan_cursor_height(conn: &rusqlite::Connection, network: &str) -> i64 { get_scan_cursor(conn, network) } + +#[cfg(test)] +mod tests { + use super::*; + + // Both helpers are private to this module, so their tests live here rather + // than in `tests/chain_scan_tests.rs`. + + #[test] + fn u32le_hex_zero_extends_a_short_push() { + // hsd's `pushU32` trims leading zero bytes, so a small height arrives + // as fewer than four bytes and must not be read as a different number. + assert_eq!(u32le_hex(""), Some(0)); + assert_eq!(u32le_hex("2a"), Some(42)); + assert_eq!(u32le_hex("2a00"), Some(42)); + assert_eq!(u32le_hex("2a000000"), Some(42)); + assert_eq!(u32le_hex("ffffffff"), Some(u32::MAX)); + } + + #[test] + fn u32le_hex_refuses_what_is_not_a_u32_push() { + // More than four bytes is some other covenant item — a name hash, say — + // and truncating it would silently invent a height. + assert_eq!(u32le_hex("2a0000000000"), None); + // Not hex at all. + assert_eq!(u32le_hex("zz"), None); + // An odd number of hex digits is not a byte string. + assert_eq!(u32le_hex("abc"), None); + } + + #[test] + fn doos_from_hns_converts_the_amounts_hsd_reports() { + assert_eq!(doos_from_hns(1.0), 1_000_000); + assert_eq!(doos_from_hns(0.000001), 1); + // Six decimals is hsd's full precision; rounding is exact there. + assert_eq!(doos_from_hns(12.345678), 12_345_678); + // Well inside f64's exact-integer range, as the doc claims: the whole + // supply cap converts without loss. + assert_eq!(doos_from_hns(2_040_000_000.0), 2_040_000_000_000_000); + } + + #[test] + fn doos_from_hns_treats_nothing_and_nonsense_as_zero() { + assert_eq!(doos_from_hns(0.0), 0); + assert_eq!(doos_from_hns(-1.0), 0); + assert_eq!(doos_from_hns(f64::NAN), 0); + assert_eq!(doos_from_hns(f64::INFINITY), 0); + } +} diff --git a/src-tauri/src/commands/daemon_ctl.rs b/src-tauri/src/commands/daemon_ctl.rs index 1c69f7f7..2a03e797 100644 --- a/src-tauri/src/commands/daemon_ctl.rs +++ b/src-tauri/src/commands/daemon_ctl.rs @@ -224,7 +224,6 @@ pub(crate) const RESOURCE_REL_DIRS: &[&str] = &[ ]; /// Return the platform-adjusted daemon binary name (appends `.exe` on Windows). -#[cfg_attr(coverage_nightly, coverage(off))] pub(crate) fn daemon_bin_name() -> String { if cfg!(target_os = "windows") { format!("{DAEMON_BIN_NAME}.exe") diff --git a/src-tauri/src/commands/deadlines.rs b/src-tauri/src/commands/deadlines.rs index 3f312acf..2b361e63 100644 --- a/src-tauri/src/commands/deadlines.rs +++ b/src-tauri/src/commands/deadlines.rs @@ -396,7 +396,7 @@ pub async fn scan_deadline_notifications( // Probe the node BEFORE taking the DB lock (guard is !Send across await, // same discipline as `read_renewals`). - let live_height = crate::commands::read::node_tip_height_if_synced(&state).await; + let live_height = crate::commands::node_readiness::node_tip_height_if_synced(&state).await; let (previously_notified, reveal, renewal) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 816e1de8..9b0cf0d6 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -13,21 +13,22 @@ //! (`db/queries.rs:1820-1875`). The critical simplification vs. block scanning //! is that `/tx/address` returns fully-decoded inputs with a resolved //! `coin { value, address, covenant }` (see hsd api-docs), so spend attribution -//! -//! COVERAGE: 97.63% line / 97.51% region — realistic ceiling. Remaining ~9 -//! missed lines are all llvm-cov region-boundary artifacts, not real gaps: -//! closing braces inside `classify_tx`'s covenant loop, the `load_wallet_addresses` -//! `query_map` row-closure, `#[tauri::command]` async-wrapper attribute lines, -//! and one sort-comparator arm the stdlib sort never invokes in a<->b order for -//! the tested inputs. The surrounding logic is all exercised. Test harness in -//! `src/tests/history_cmd_tests.rs` uses `MockNodeRpc` for unit tests and -//! mockito regex-match on `GET /tx/address/:addr` for integration tests. //! needs no extra `getrawtransaction` roundtrips. //! //! Covenant constants come from `noncustodial::sync` (verified against hsd //! `lib/covenants/rules.js`). We rely on the numeric `covenant.type`, NOT the //! symbolic `action` string, because the `POST /tx/address` bulk route omits //! the string (and we may add bulk later); the numeric type is always present. +//! +//! Coverage: what this module does not reach is llvm-cov region boundaries +//! rather than untested logic — closing braces inside `classify_tx`'s covenant +//! loop, the `load_wallet_addresses` `query_map` row closure, +//! `#[tauri::command]` async-wrapper attribute lines, and a sort-comparator arm +//! the stdlib sort never invokes in a<->b order for the tested inputs. The test +//! harness in `src/tests/history_cmd_tests.rs` drives `MockNodeRpc` for unit +//! tests and a mockito regex match on `GET /tx/address/:addr` for integration +//! tests. (A percentage used to be quoted here; it went stale the first time +//! anyone touched the file, so the shape of the gap is written down instead.) use std::collections::{BTreeMap, HashSet}; @@ -35,7 +36,6 @@ use serde::Serialize; use tauri::State; use crate::commands::read::resolve_profile; -use crate::db::queries; use crate::error::AppError; use crate::noncustodial::sync::{ COV_BID, COV_CLAIM, COV_FINALIZE, COV_NONE, COV_OPEN, COV_REDEEM, COV_REGISTER, COV_RENEW, @@ -326,17 +326,13 @@ pub async fn read_action_history( }; // Snapshot addresses + build the node client under a short DB lock; drop before .await. - // Per-profile node override routing (ADR-001): if an active profile exists, - // use its effective node config; otherwise fall back to global settings. + // Per-profile node override routing (ADR-001): this profile's effective + // node config, and an error when it will not resolve. Falling back to + // global would list another node's view of this wallet's history. let (addresses, node) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let addrs = load_wallet_addresses(&conn, &profile_id)?; - let client = crate::noncustodial::rpc::NodeRpcClient::for_profile(&conn, &profile_id) - .unwrap_or_else(|_| { - // Fallback to global settings if profile config is missing or misconfigured. - let settings = queries::get_settings(&conn).unwrap_or_default(); - crate::noncustodial::rpc::NodeRpcClient::from_settings(&settings) - }); + let client = crate::noncustodial::rpc::NodeRpcClient::for_profile(&conn, &profile_id)?; (addrs, client) }; if addresses.is_empty() { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b65a7430..ff0b3f05 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod namebase_history; pub mod names; pub mod names_pure; pub mod node; +pub mod node_readiness; pub mod paid_swaps; pub mod read; pub mod read_pure; diff --git a/src-tauri/src/commands/namebase.rs b/src-tauri/src/commands/namebase.rs index 7fd098e1..e72c859c 100644 --- a/src-tauri/src/commands/namebase.rs +++ b/src-tauri/src/commands/namebase.rs @@ -2,10 +2,10 @@ // * #[tauri::command] macro attribute lines are structurally uncoverable — the // macro is expanded at compile time and does not execute at runtime. // * The `read_cookie` keyring-unavailable branch (Err(_e) => ... return legacy) -// fires only when `cookie_vault::encrypt_cookie` fails because the OS keyring -// is unreachable. Tests install a fixed test DEK (so encryption always -// succeeds); forcing this branch would require an OS-level keyring failure -// that the test harness can't produce deterministically. +// fires when `cookie_vault::encrypt_cookie` fails because the OS keyring is +// unreachable. `cookie_vault::set_keyring_unavailable_for_test` installs an +// always-failing backend to drive it; see +// `namebase_cmd_tests::read_cookie_returns_legacy_plaintext_when_keyring_unavailable`. // * The poisoned-Mutex fallback in `commands::active_profile::active_profile_network` // (Err(_) => Network::Main) requires a panicked lock holder — not reachable // through the command surface. diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index a79b5889..5953e058 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -338,14 +338,15 @@ pub enum AuctionTaskState { UnavailableOther, } -/// Days-until-expiry threshold below which an owned name's task state becomes -/// [`AuctionTaskState::ExpiringSoon`] (and the Renewals screen flags the row). -/// A missed renewal on Handshake loses the name forever, so this errs early. -/// (A settings-configurable threshold was considered and skipped for now — -/// the constant is the single source of truth, surfaced to the frontend via -/// `read_renewals.expiringSoonThresholdDays`.) -/// Mainnet's expiry warning threshold, kept as a named constant because tests -/// and the notification default both pin the historical 30-day behaviour. +/// Mainnet's days-until-expiry threshold below which an owned name's task +/// state becomes [`AuctionTaskState::ExpiringSoon`] (and the Renewals screen +/// flags the row). A missed renewal on Handshake loses the name forever, so +/// this errs early. Kept as a named constant because tests and the +/// notification default both pin the historical 30-day behaviour, and +/// surfaced to the frontend via `read_renewals.expiringSoonThresholdDays`. +/// +/// Not configurable: a settings-controlled threshold was considered and +/// skipped. /// Live code reads /// [`crate::noncustodial::network::Network::expiring_soon_threshold_days`] /// instead, which scales this to the network's own renewal window — a flat 30 @@ -443,6 +444,11 @@ pub struct NameActionCapabilities { } /// Context gathered from the DB for a name action evaluation. +/// +/// `Default` is the no-evidence state — nothing held, nothing pending, nothing +/// known — which is what the capability model falls back to and what a test +/// wants as a base before naming the one or two facts it is about. +#[derive(Default)] pub(crate) struct NameActionContext { pub has_bid_commitment: bool, /// Unspent COV_BID coin for this name (the coin a REVEAL spends). Gates @@ -524,22 +530,16 @@ pub(crate) fn find_name_action_context( auction_start: Option, ) -> Result { // Newest first, so the first match is the most recent bid in this auction. - let for_name: Vec = queries::list_bid_commitments(conn, profile_id) - .unwrap_or_default() + // A DB failure is returned, not read as "this wallet has never bid": the + // capabilities built from it decide whether Reveal and Redeem are offered, + // and an empty list withdraws both from a wallet that has money locked up. + let for_name: Vec = queries::list_bid_commitments(conn, profile_id)? .into_iter() .filter(|b| b.name == name) .collect(); - let belongs_here = |b: &queries::BidCommitmentRow| match (auction_start, b.name_start_height) { - (Some(start), Some(placed)) => placed == start, - // A commitment recovered from the chain has no recorded auction (030). - // Counting one that may be dead is a wrong number; hiding a live one is - // a bid the user never gets told to reveal. - (Some(_), None) => true, - (None, _) => true, - }; let commitments: Vec = for_name .iter() - .filter(|b| belongs_here(b)) + .filter(|b| b.belongs_to_auction(auction_start)) .cloned() .collect(); let bid = commitments.first().cloned(); @@ -555,7 +555,7 @@ pub(crate) fn find_name_action_context( // with nothing on screen to explain it. let (stranded_bid_count, stranded_lockup_doos) = for_name .iter() - .filter(|b| !belongs_here(b)) + .filter(|b| !b.belongs_to_auction(auction_start)) .filter(|b| { queries::find_unspent_covenant_utxo( conn, @@ -583,7 +583,11 @@ pub(crate) fn find_name_action_context( // lands back on the bid coin's own address, see `build_reveal_draft`), so // only the covenant type differs between the two queries below. // - let name_hash_hex = hex::encode(names::hash_name(name).unwrap_or([0u8; 32])); + // A zero hash is not a name. `hash_name` only fails on a name that is not + // valid, and the coin lookups below are keyed by this hash — so the + // fallback answered "no reveal coins" for every name, which reads as + // nothing to redeem. + let name_hash_hex = hex::encode(names::hash_name(name)?); // Every bid of THIS auction, not the newest one and not every bid the // profile has ever placed on the name. Both wrong answers were live: // picking one commitment's address was never well defined (`created_at` @@ -595,28 +599,27 @@ pub(crate) fn find_name_action_context( // with "no unspent bid coin". This is the same set `build_reveal_draft` // builds its transaction from, so the button and the builder cannot // disagree. - let bid_coin = commitments.iter().find_map(|b| { - queries::find_unspent_covenant_utxo( + let mut bid_coin = None; + for b in &commitments { + if let Some(coin) = queries::find_unspent_covenant_utxo( conn, profile_id, &b.address, sync::COV_BID as i64, name, &b.name_hash_hex, - ) - .ok() - .flatten() - }); + )? { + bid_coin = Some(coin); + break; + } + } let reveal_coins = queries::find_unspent_covenant_utxos_by_name_hash( conn, profile_id, COV_REVEAL as i64, &name_hash_hex, - ) - .unwrap_or_default(); - let owner_coin = queries::get_name_coin(conn, profile_id, name) - .ok() - .flatten(); + )?; + let owner_coin = queries::get_name_coin(conn, profile_id, name)?; // A reveal coin that is NOT the name's owner is a losing bid this wallet // can still reclaim. Outbidding yourself leaves exactly this: you own the // name AND hold losing reveals on it. @@ -657,24 +660,16 @@ pub(crate) fn find_name_action_context( // permanently un-openable — including one whose auction had since lapsed // and which the chain now reports as available again. A live auction is // already handled by the phase check in `build_name_action_capabilities`. - let has_pending_open_coin = names::hash_name(name) - .ok() - .map(hex::encode) - .map(|nh_hex| { - queries::has_unconfirmed_covenant_utxo_by_name_hash( - conn, - profile_id, - sync::COV_OPEN as i64, - &nh_hex, - ) - .unwrap_or(false) - }) - .unwrap_or(false); + let has_pending_open_coin = queries::has_unconfirmed_covenant_utxo_by_name_hash( + conn, + profile_id, + sync::COV_OPEN as i64, + &hex::encode(names::hash_name(name)?), + )?; let has_pending_open_draft = - queries::has_pending_draft_for_name(conn, profile_id, "open", name).unwrap_or(false); + queries::has_pending_draft_for_name(conn, profile_id, "open", name)?; let has_pending_open = has_pending_open_coin || has_pending_open_draft; - let pending_actions = - queries::pending_broadcast_actions_for_name(conn, profile_id, name).unwrap_or_default(); + let pending_actions = queries::pending_broadcast_actions_for_name(conn, profile_id, name)?; let pending_broadcast_action = pending_actions.first().cloned(); // `get_name_coin` answers "can we spend it" and returns unspent coins @@ -715,21 +710,23 @@ pub(crate) fn find_name_action_context( // dropped/failed; when there's no draft (restored/cross-device wallet), the // caller falls back to the bid-coin-spent chain fact. let reveal_txid = bid.as_ref().and_then(|b| b.reveal_txid.clone()); - let reveal_draft_status = reveal_txid.as_ref().and_then(|txid| { - queries::get_draft_status_by_txid(conn, profile_id, txid) - .ok() - .flatten() - }); + let reveal_draft_status = match reveal_txid.as_ref() { + Some(txid) => queries::get_draft_status_by_txid(conn, profile_id, txid)?, + None => None, + }; let bid_value_doos = bid.as_ref().map(|b| b.bid_value_doos); let lockup_value_doos = bid.as_ref().map(|b| b.lockup_value_doos); - let tracked_row = queries::get_tracked_name_state(conn, profile_id, name).unwrap_or(None); + let tracked_row = queries::get_tracked_name_state(conn, profile_id, name)?; let transfer_height = tracked_row .as_ref() .and_then(|t| t.transfer_height) .filter(|h| *h > 0); + // The same call is already propagated further down this file; a failure + // here read as "height unknown", which quietly widens every gate that + // compares a height against the tip. let current_height = - crate::commands::read::estimate_persisted_height(conn, profile_id).unwrap_or(None); + crate::commands::node_readiness::estimate_persisted_height(conn, profile_id)?; Ok(NameActionContext { has_bid_commitment: bid.is_some(), @@ -790,7 +787,7 @@ pub async fn get_name_action_capabilities( Some(id) => id, None => return Ok(conservative_capabilities(&name, "no active wallet profile")), }; - let live_tip = crate::commands::read::node_tip_height_if_synced(&state).await; + let live_tip = crate::commands::node_readiness::node_tip_height_if_synced(&state).await; evaluate_name_action_capabilities(&state, name, &profile_id, live_tip).await } @@ -836,7 +833,7 @@ pub async fn get_names_action_capabilities( // Fetched once for the whole batch, not once per name: the only thing it // is needed for is the transfer-lockup countdown, and a stale tip there // refuses a FINALIZE the node would accept. - let live_tip = crate::commands::read::node_tip_height_if_synced(&state).await; + let live_tip = crate::commands::node_readiness::node_tip_height_if_synced(&state).await; let mut out = Vec::with_capacity(names.len()); for name in names { out.push(evaluate_name_action_capabilities(&state, name, &profile_id, live_tip).await?); @@ -863,9 +860,8 @@ async fn evaluate_name_action_capabilities( let (client, network) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let client = NodeRpcClient::for_profile(&conn, profile_id)?; - let network = queries::get_wallet_profile(&conn, profile_id)? - .and_then(|p| Network::from_str_opt(&p.network)) - .unwrap_or_default(); + let network = + crate::commands::active_profile::profile_network_from_conn(&conn, profile_id)?; (client, network) }; @@ -875,7 +871,7 @@ async fn evaluate_name_action_capabilities( // spuriously false for names we actually own. Treat "reachable but not // synced" exactly like unreachable: fall back to local Sync evidence. // Reuses the same gate as `read_balance`/`read_names` — no duplicate logic. - let name_info = if crate::commands::read::is_node_ready_for_local_reads(state).await { + let name_info = if crate::commands::node_readiness::is_node_ready_for_local_reads(state).await { client.get_name_info(&name).await.ok() } else { None @@ -907,13 +903,7 @@ async fn evaluate_name_action_capabilities( }; let stats = name_info.get("info").and_then(|i| i.get("stats")); - // The persisted estimate is deliberately conservative — on regtest - // it does not age at all — and the transfer-lockup gate is the one - // consumer where a stale tip refuses an action the node accepts. - let action_ctx = NameActionContext { - current_height: live_tip.or(action_ctx.current_height), - ..action_ctx - }; + let action_ctx = action_ctx.with_live_tip(live_tip); let NameOwnership { owns_name, spend_locked, @@ -957,7 +947,7 @@ async fn evaluate_name_action_capabilities( // duplicated — both read the same helpers. let renewal_window = network.name_params().renewal_window as i64; let current_height = - crate::commands::read::estimate_persisted_height(&conn, profile_id)?; + crate::commands::node_readiness::estimate_persisted_height(&conn, profile_id)?; (tracked, action_ctx, addrs, renewal_window, current_height) }; let tracked = match tracked { @@ -970,13 +960,7 @@ async fn evaluate_name_action_capabilities( .as_deref() .map(|s| s.to_uppercase()) .unwrap_or_default(); - // The persisted estimate is deliberately conservative — on regtest - // it does not age at all — and the transfer-lockup gate is the one - // consumer where a stale tip refuses an action the node accepts. - let action_ctx = NameActionContext { - current_height: live_tip.or(action_ctx.current_height), - ..action_ctx - }; + let action_ctx = action_ctx.with_live_tip(live_tip); let NameOwnership { owns_name, spend_locked, @@ -1099,21 +1083,18 @@ pub(crate) fn build_name_action_capabilities( // the covenant type is below REGISTER (i.e. not already registered). let registration_needed = phase == "CLOSED" && action_ctx.has_owner_coin - && action_ctx - .owner_covenant_type - .map(|t| t < COV_REGISTER as i64) - .unwrap_or(true); + && !crate::noncustodial::covenants::is_registered_owner_covenant( + action_ctx.owner_covenant_type, + ); let can_register = NameActionCapability { allowed: registration_needed, reason: if phase != "CLOSED" { Some(format!("auction not yet closed (phase: '{phase}')")) } else if !action_ctx.has_owner_coin { Some("wallet does not own the winning name coin".into()) - } else if action_ctx - .owner_covenant_type - .map(|t| t >= COV_REGISTER as i64) - .unwrap_or(false) - { + } else if crate::noncustodial::covenants::is_registered_owner_covenant( + action_ctx.owner_covenant_type, + ) { Some("name is already registered".into()) } else { None @@ -1127,13 +1108,23 @@ pub(crate) fn build_name_action_capabilities( // already names the highest revealer as the owner, so the wallet looked // like it owned a name it had not won yet, and offered Update, Transfer, // Renew and Revoke on it. - let name_is_registered = action_ctx - .owner_covenant_type - .map(|t| t >= COV_REGISTER as i64) - .unwrap_or(false); + let name_is_registered = crate::noncustodial::covenants::is_registered_owner_covenant( + action_ctx.owner_covenant_type, + ); let can_spend_as_owner = owns_name && name_is_registered; - let not_registered_reason = "the name is not registered yet"; let transfer_pending = action_ctx.transfer_has_items.unwrap_or(false); + // Why an ownership action is refused before its own condition is looked + // at. Every owner action opens with the same two questions, in this order; + // the sentence each answers with is written here once. + let owner_gate = || -> Option { + if !owns_name { + Some("wallet does not control this name".into()) + } else if !name_is_registered { + Some("the name is not registered yet".into()) + } else { + None + } + }; // Update is the one ownership action a pending transfer takes away. hsd // lets a TRANSFER coin go to UPDATE, RENEW, FINALIZE or REVOKE @@ -1144,15 +1135,10 @@ pub(crate) fn build_name_action_capabilities( // own name. let can_update = NameActionCapability { allowed: can_spend_as_owner && !transfer_pending, - reason: if !owns_name { - Some("wallet does not control this name".into()) - } else if !name_is_registered { - Some(not_registered_reason.into()) - } else if transfer_pending { - Some("a transfer is pending — updating records would cancel it".into()) - } else { - None - }, + reason: owner_gate().or_else(|| { + transfer_pending + .then(|| "a transfer is pending — updating records would cancel it".into()) + }), }; // A TRANSFER coin may go to UPDATE, RENEW, FINALIZE or REVOKE — never to @@ -1160,15 +1146,10 @@ pub(crate) fn build_name_action_capabilities( // the user at a transaction the node refuses. let can_transfer = NameActionCapability { allowed: can_spend_as_owner && !transfer_pending, - reason: if !owns_name { - Some("wallet does not control this name".into()) - } else if !name_is_registered { - Some(not_registered_reason.into()) - } else if transfer_pending { - Some("a transfer is already pending — finalize or cancel it first".into()) - } else { - None - }, + reason: owner_gate().or_else(|| { + transfer_pending + .then(|| "a transfer is already pending — finalize or cancel it first".into()) + }), }; // hsd refuses a FINALIZE until `transfer + transfer_lockup` blocks have @@ -1186,12 +1167,10 @@ pub(crate) fn build_name_action_capabilities( let finalize_matured = blocks_until_finalize.map(|b| b == 0).unwrap_or(true); let can_finalize = NameActionCapability { - allowed: can_spend_as_owner - && action_ctx.transfer_has_items.unwrap_or(false) - && finalize_matured, + allowed: can_spend_as_owner && transfer_pending && finalize_matured, reason: if !owns_name { Some("wallet does not control this name".into()) - } else if !action_ctx.transfer_has_items.unwrap_or(false) { + } else if !transfer_pending { Some("name is not in TRANSFER state".into()) } else { blocks_until_finalize.filter(|b| *b > 0).map(|blocks| { @@ -1208,15 +1187,8 @@ pub(crate) fn build_name_action_capabilities( // name and built an UPDATE that changes nothing and costs a fee. let can_cancel_transfer = NameActionCapability { allowed: can_spend_as_owner && transfer_pending, - reason: if !owns_name { - Some("wallet does not control this name".into()) - } else if !name_is_registered { - Some(not_registered_reason.into()) - } else if !transfer_pending { - Some("name is not in TRANSFER state".into()) - } else { - None - }, + reason: owner_gate() + .or_else(|| (!transfer_pending).then(|| "name is not in TRANSFER state".into())), }; // Renew is Update's twin here: hsd's RENEW handler runs `ns.setTransfer(0)` @@ -1225,26 +1197,14 @@ pub(crate) fn build_name_action_capabilities( // renewal is one click away; the reverse order loses the transfer silently. let can_renew = NameActionCapability { allowed: can_spend_as_owner && !transfer_pending, - reason: if !owns_name { - Some("wallet does not control this name".into()) - } else if !name_is_registered { - Some(not_registered_reason.into()) - } else if transfer_pending { - Some("a transfer is pending — renewing would cancel it".into()) - } else { - None - }, + reason: owner_gate().or_else(|| { + transfer_pending.then(|| "a transfer is pending — renewing would cancel it".into()) + }), }; let can_revoke = NameActionCapability { allowed: can_spend_as_owner, - reason: if !owns_name { - Some("wallet does not control this name".into()) - } else if !name_is_registered { - Some(not_registered_reason.into()) - } else { - None - }, + reason: owner_gate(), }; // 4b. Spend lock: when we can't build a spend (no node-synced owner coin), @@ -1306,21 +1266,8 @@ pub(crate) fn build_name_action_capabilities( }) }) }); - let task_state = derive_auction_task_state( - &phase, - owns_name, - action_ctx.has_bid_commitment, - action_ctx.has_bid_coin, - action_ctx.has_reveal_coin, - action_ctx.has_owner_coin, - action_ctx.owner_covenant_type, - days_until_expire, - action_ctx.has_pending_open, - transfer_pending, - action_ctx.reveal_txid.as_deref(), - action_ctx.reveal_draft_status.as_deref(), - network, - ); + let task_state = + derive_auction_task_state(action_ctx, &phase, owns_name, days_until_expire, network); // 6. Determine next action. let (next_action_key, next_action_label, mut next_action_reason) = @@ -1329,7 +1276,7 @@ pub(crate) fn build_name_action_capabilities( // `WaitingForBidding` is reused for two distinct situations: a pending OPEN // that hasn't reached BIDDING yet (default reason "The auction opens for // bidding soon.") and a name already in the on-chain BIDDING phase that - // THIS wallet has already bid on (one bid per wallet per name). For the + // THIS wallet has already bid on. For the // latter the default reason reads wrong — bidding is already open and the // wallet's action is to wait for the reveal window, not for bidding to // start — so refine the reason to match the "your bid is placed" panel the @@ -1470,6 +1417,21 @@ pub(crate) struct NameOwnership { pub spend_locked: bool, } +impl NameActionContext { + /// Prefer a tip read from a synced node over the persisted estimate. + /// + /// The estimate is deliberately conservative — on regtest it does not age + /// at all — and the transfer-lockup gate is the one consumer where a stale + /// tip refuses an action the node would accept. `None` leaves the estimate + /// in place, which is what happens with no synced node to ask. + pub(crate) fn with_live_tip(self, live_tip: Option) -> Self { + Self { + current_height: live_tip.or(self.current_height), + ..self + } + } +} + /// `owner_address` is the owner recorded for the name (from the node payload /// or the tracked row); it counts as ours when it is one of `profile_addrs`. pub(crate) fn derive_name_ownership( @@ -1514,31 +1476,30 @@ pub(crate) fn derive_name_ownership( /// variant (its "Wait for Bidding" label reads fine for "your OPEN is /// confirming") rather than adding a new one. #[allow(clippy::too_many_arguments)] -pub fn derive_auction_task_state( +/// +/// Nine of the facts this needs travel together in [`NameActionContext`] and +/// are read from it by name. They used to be nine positional parameters, six +/// of them `bool`, where swapping two adjacent ones compiled silently and +/// changed the answer. +/// +/// A pending transfer comes from the context too, and is NOT derivable from +/// `phase`: hsd's name states are OPENING / LOCKED / BIDDING / REVEAL / CLOSED +/// / REVOKED (`namestate.js`), and a transfer leaves the state at CLOSED, +/// signalling itself through `info.transfer` instead. +pub(crate) fn derive_auction_task_state( + ctx: &NameActionContext, phase: &str, owns_name: bool, - has_bid_commitment: bool, - has_bid_coin: bool, - has_reveal_coin: bool, - has_owner_coin: bool, - owner_covenant_type: Option, days_until_expire: Option, - has_pending_open: bool, - // `transfer_pending`: a TRANSFER is recorded for this name. NOT derivable - // from `phase` — hsd's name states are OPENING / LOCKED / BIDDING / - // REVEAL / CLOSED / REVOKED (`namestate.js`), and a transfer leaves the - // state at CLOSED, signalling itself through `info.transfer` instead. - transfer_pending: bool, - reveal_txid: Option<&str>, - reveal_draft_status: Option<&str>, network: Network, ) -> AuctionTaskState { + let transfer_pending = ctx.transfer_has_items.unwrap_or(false); let expiring_soon = days_until_expire .map(|d| d <= network.expiring_soon_threshold_days()) .unwrap_or(false); match phase { "AVAILABLE" | "" => { - if has_pending_open { + if ctx.has_pending_open { AuctionTaskState::WaitingForBidding } else { AuctionTaskState::AvailableToOpen @@ -1553,11 +1514,10 @@ pub fn derive_auction_task_state( // header + the distinguished "yours" rows in the bid list convey // that a bid is already placed; we no longer collapse to a // terminal WaitingForBidding state that hides the form. - let _ = has_bid_commitment; AuctionTaskState::ReadyToBid } "REVEAL" => { - if !has_bid_commitment { + if !ctx.has_bid_commitment { return AuctionTaskState::UnavailableOther; } // Reveal state machine (grilled design): prefer a local draft's @@ -1565,18 +1525,18 @@ pub fn derive_auction_task_state( // 1. Local draft exists: broadcasted/broadcast_pending → pending; // confirmed → done; dropped/failed → back to ReadyToReveal so // the user can re-broadcast (the bid coin is still unspent). - // 2. No draft but reveal_txid set AND the bid coin is spent - // (!has_bid_coin) → done (chain ground truth; covers restored / + // 2. No draft but ctx.reveal_txid.as_deref() set AND the bid coin is spent + // (!ctx.has_bid_coin) → done (chain ground truth; covers restored / // cross-device wallets that revealed elsewhere). // 3. Otherwise → ReadyToReveal (still prompt; `can_reveal.allowed`, - // which requires has_bid_coin, is the real button gate). - match reveal_draft_status { + // which requires ctx.has_bid_coin, is the real button gate). + match ctx.reveal_draft_status.as_deref() { Some("broadcasted") | Some("broadcast_pending") => { AuctionTaskState::RevealBroadcastPending } Some("confirmed") => AuctionTaskState::RevealDoneWaitingForClose, _ => { - if reveal_txid.is_some() && !has_bid_coin { + if ctx.reveal_txid.as_deref().is_some() && !ctx.has_bid_coin { AuctionTaskState::RevealDoneWaitingForClose } else { AuctionTaskState::ReadyToReveal @@ -1585,13 +1545,14 @@ pub fn derive_auction_task_state( } } "CLOSED" => { - if owns_name && has_owner_coin { + if owns_name && ctx.has_owner_coin { // If the owner coin is already REGISTER (6) or higher (UPDATE, // RENEW, TRANSFER, etc.), the name is already registered — no // REGISTER action needed. A coin with covenant type < COV_REGISTER // (e.g. OPEN=2, REVEAL=4) means the wallet just won but has not // yet registered. - let already_registered = owner_covenant_type + let already_registered = ctx + .owner_covenant_type .map(|t| t >= COV_REGISTER as i64) .unwrap_or(false); if already_registered { @@ -1603,7 +1564,7 @@ pub fn derive_auction_task_state( // ahead of everything quiet, because finalizing is the // one thing the name is waiting on. AuctionTaskState::TransferPendingFinalize - } else if has_reveal_coin { + } else if ctx.has_reveal_coin { // Registered, and still holding a REVEAL coin. The // winning one was spent by that REGISTER, so whatever // is left lost — this wallet outbid itself, and those @@ -1631,7 +1592,7 @@ pub fn derive_auction_task_state( } else { AuctionTaskState::OwnedNoUrgentAction } - } else if has_reveal_coin { + } else if ctx.has_reveal_coin { AuctionTaskState::LostNeedsRedeem } else { AuctionTaskState::OwnedNoUrgentAction @@ -2351,9 +2312,7 @@ pub async fn build_redeem_draft( // The winning reveal IS the name's owner coin until REGISTER spends it, // and consensus rejects redeeming it (`bad-redeem-owner`) — which would // take the whole transaction down with it. - let owner = queries::get_name_coin(&conn, &ctx.profile_id, &name) - .ok() - .flatten(); + let owner = queries::get_name_coin(&conn, &ctx.profile_id, &name)?; all.into_iter() .filter(|c| { owner @@ -2866,8 +2825,7 @@ pub async fn build_batch_renew_draft( let client = ctx.node.clone(); let rblock = renewal_block(&client, ctx.network).await?; - let mut per_name: Vec<(String, [u8; 32], queries::NameCoin, NameState)> = - Vec::with_capacity(names.len()); + let mut per_name: PerNameOwner = Vec::with_capacity(names.len()); for name in &names { let nh = names::hash_name(name)?; let (coin, ns) = owner_coin_and_state(&state, &ctx, name).await?; @@ -2875,7 +2833,7 @@ pub async fn build_batch_renew_draft( } let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; - build_batch_renew_draft_inner(&conn, &ctx, &names, per_name, &rblock, rate) + build_batch_renew_draft_inner(&conn, &ctx, per_name, &rblock, rate) } /// Pure inner logic for `build_batch_renew_draft`, testable without a Tauri @@ -2887,8 +2845,7 @@ pub async fn build_batch_renew_draft( pub(crate) fn build_batch_renew_draft_inner( conn: &rusqlite::Connection, ctx: &Ctx, - names: &[String], - per_name: Vec<(String, [u8; 32], queries::NameCoin, NameState)>, + per_name: PerNameOwner, rblock: &[u8; 32], rate: u64, ) -> Result { @@ -2919,7 +2876,6 @@ pub(crate) fn build_batch_renew_draft_inner( )?; // Persist with first name as primary; the draft plan contains all names. let display_name = names_pure::display_names(&batch_names); - let _ = names; // kept for API parity; batch_names carries the actual list let name_refs: Vec<&str> = batch_names.iter().map(|s| s.as_str()).collect(); persist_with_conn( conn, @@ -2956,8 +2912,7 @@ pub async fn build_batch_transfer_draft( // whole batch before any owner-coin prefetch or DB write. let (version, program) = address::decode(ctx.network, &recipient)?; - let mut per_name: Vec<(String, [u8; 32], queries::NameCoin, NameState)> = - Vec::with_capacity(names.len()); + let mut per_name: PerNameOwner = Vec::with_capacity(names.len()); for name in &names { let nh = names::hash_name(name)?; let (coin, ns) = owner_coin_and_state(&state, &ctx, name).await?; @@ -2965,9 +2920,7 @@ pub async fn build_batch_transfer_draft( } let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; - build_batch_transfer_draft_inner( - &conn, &ctx, &names, per_name, &recipient, version, &program, rate, - ) + build_batch_transfer_draft_inner(&conn, &ctx, per_name, &recipient, version, &program, rate) } /// Pure inner logic for `build_batch_transfer_draft`, testable without a Tauri @@ -2984,8 +2937,7 @@ pub async fn build_batch_transfer_draft( pub(crate) fn build_batch_transfer_draft_inner( conn: &rusqlite::Connection, ctx: &Ctx, - names: &[String], - per_name: Vec<(String, [u8; 32], queries::NameCoin, NameState)>, + per_name: PerNameOwner, recipient: &str, version: u8, program: &[u8], @@ -3017,7 +2969,6 @@ pub(crate) fn build_batch_transfer_draft_inner( rate, )?; let display_name = names_pure::display_names(&batch_names); - let _ = names; // kept for API parity; batch_names carries the actual list let name_refs: Vec<&str> = batch_names.iter().map(|s| s.as_str()).collect(); persist_with_conn( conn, @@ -3055,13 +3006,7 @@ pub async fn build_batch_reveal_draft( // then async RPC with NO lock held — preserving the original per-name // lock/unlock discipline. The pure computation (nonce parse + covenant + // plan + persist) runs afterward under one final held lock in the inner. - let mut per_name: Vec<( - String, - [u8; 32], - queries::BidCommitmentRow, - queries::NameCoin, - NameState, - )> = Vec::with_capacity(names.len()); + let mut per_name: PerNameBid = Vec::with_capacity(names.len()); for name in &names { let nh = names::hash_name(name)?; // Async RPC first — its `height` says which auction is running, and a @@ -3130,13 +3075,7 @@ pub async fn build_batch_reveal_draft( pub(crate) fn build_batch_reveal_draft_inner( conn: &rusqlite::Connection, ctx: &Ctx, - per_name: Vec<( - String, - [u8; 32], - queries::BidCommitmentRow, - queries::NameCoin, - NameState, - )>, + per_name: PerNameBid, rate: u64, ) -> Result { let mut primaries = Vec::with_capacity(per_name.len()); @@ -3221,13 +3160,7 @@ pub async fn build_batch_redeem_draft( // commitment + reveal-coin lookup — preserving the original per-iteration // RPC-then-lock discipline. The pure computation (covenant + plan + // persist) runs afterward under one final held lock in the inner. - let mut per_name: Vec<( - String, - [u8; 32], - queries::BidCommitmentRow, - queries::NameCoin, - NameState, - )> = Vec::with_capacity(names.len()); + let mut per_name: PerNameBid = Vec::with_capacity(names.len()); for name in &names { let nh = names::hash_name(name)?; let ns = fetch_name_state(&client, name).await?; @@ -3268,13 +3201,7 @@ pub async fn build_batch_redeem_draft( pub(crate) fn build_batch_redeem_draft_inner( conn: &rusqlite::Connection, ctx: &Ctx, - per_name: Vec<( - String, - [u8; 32], - queries::BidCommitmentRow, - queries::NameCoin, - NameState, - )>, + per_name: PerNameBid, rate: u64, ) -> Result { let mut primaries = Vec::with_capacity(per_name.len()); @@ -3350,6 +3277,25 @@ pub async fn build_batch_finalize_draft( /// Per-name row for [`build_batch_finalize_draft_inner`]: /// `(name, name_hash, raw_name, owner_coin, on_chain_state)`. +/// Per-name prefetch for a batch that spends the name's owner coin: the name, +/// its hash, that coin, and the on-chain state it was read with. +/// +/// Named for the same reason [`PerNameFinalize`] is: four signatures carried +/// this shape written out, and a bare tuple says nothing about which +/// `[u8; 32]` or which of two coins is which. +pub(crate) type PerNameOwner = Vec<(String, [u8; 32], queries::NameCoin, NameState)>; + +/// Per-name prefetch for a batch that spends a bid: the name, its hash, the +/// commitment row the bid was made from, the coin it created, and the on-chain +/// state. Used by reveal and by redeem, which ignores the commitment. +pub(crate) type PerNameBid = Vec<( + String, + [u8; 32], + queries::BidCommitmentRow, + queries::NameCoin, + NameState, +)>; + pub(crate) type PerNameFinalize = Vec<(String, [u8; 32], Vec, queries::NameCoin, NameState)>; /// Pure inner logic for `build_batch_finalize_draft`, testable without a Tauri @@ -3634,18 +3580,20 @@ mod tests { reveal_draft_status: Option<&str>, ) -> AuctionTaskState { derive_auction_task_state( + &NameActionContext { + has_bid_commitment, + has_bid_coin, + has_reveal_coin, + has_owner_coin, + owner_covenant_type, + has_pending_open, + reveal_txid: reveal_txid.map(str::to_string), + reveal_draft_status: reveal_draft_status.map(str::to_string), + ..Default::default() + }, phase, owns_name, - has_bid_commitment, - has_bid_coin, - has_reveal_coin, - has_owner_coin, - owner_covenant_type, days_until_expire, - has_pending_open, - false, - reveal_txid, - reveal_draft_status, Network::Main, ) } @@ -4078,17 +4026,14 @@ mod tests { // has no transfer to give it. assert_eq!( derive_auction_task_state( + &NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_TRANSFER as i64), + transfer_has_items: Some(true), + ..Default::default() + }, "CLOSED", - true, - false, - false, - false, - true, - Some(COV_TRANSFER as i64), - None, - false, - true, - None, + /* owns_name */ true, None, Network::Main, ), @@ -4811,6 +4756,28 @@ mod tests { /// Without heights we cannot say, and refusing an action the node would /// accept is its own kind of wrong. A transfer with no recorded height /// stays offered. + /// The persisted estimate is conservative on purpose (regtest never ages + /// it), so a synced node's tip replaces it; with no node to ask, nothing + /// changes. + #[test] + fn a_live_tip_replaces_the_persisted_estimate() { + let ctx = NameActionContext { + current_height: Some(805), + ..ctx_default() + }; + assert_eq!(ctx.with_live_tip(Some(812)).current_height, Some(812)); + let ctx = NameActionContext { + current_height: Some(805), + ..ctx_default() + }; + assert_eq!(ctx.with_live_tip(None).current_height, Some(805)); + let ctx = NameActionContext { + current_height: None, + ..ctx_default() + }; + assert_eq!(ctx.with_live_tip(Some(812)).current_height, Some(812)); + } + #[test] fn finalize_is_not_blocked_when_the_lockup_is_unknown() { let ctx = NameActionContext { diff --git a/src-tauri/src/commands/node.rs b/src-tauri/src/commands/node.rs index 07b1a5d2..6fed0a3e 100644 --- a/src-tauri/src/commands/node.rs +++ b/src-tauri/src/commands/node.rs @@ -143,7 +143,15 @@ fn format_version(v: (u32, u32, u32)) -> String { format!("{}.{}.{}", v.0, v.1, v.2) } -/// The configured hsd data directory, or hsd's own default (`~/.hsd`) when unset. +/// The configured hsd data directory, or hsd's own default (`~/.hsd`) when +/// unset, for the active profile's network. +/// +/// The network is read through the reader that degrades to mainnet, which is +/// fine for *reporting* a path and not for acting on one. A caller that +/// spawns a node, moves chain data, or otherwise commits to a directory +/// resolves the network itself and calls +/// [`resolve_data_dir_for_network`], so the answer it refused to guess is the +/// answer it uses. fn resolve_data_dir(state: &AppState) -> Result { let network = active_profile_network(state); resolve_data_dir_for_network(state, network) @@ -238,19 +246,22 @@ async fn probe_node(state: &AppState) -> Option { // Per-profile node override routing (ADR-001): probe the active profile's // effective node config (per-profile override -> global settings -> // built-in default) so the tray/status reflects the active profile's node. - // Fall back to global settings when there is no active profile or its - // config is missing/misconfigured. + // With no active profile at all there is no override to honour, so global + // is the whole answer; a profile whose own config will not resolve is a + // configuration error, and this probe reports "cannot tell" rather than + // quietly describing a node the profile does not use. let client = { let db = state.db.lock().ok()?; match db::queries::get_active_profile_id(&db) { - Ok(profile_id) => NodeRpcClient::for_profile(&db, &profile_id).unwrap_or_else(|_| { - let settings = db::queries::get_settings(&db).unwrap_or_default(); - NodeRpcClient::from_settings(&settings) - }), - Err(_) => { + // No active profile — the id is stored as an empty string, not an + // error — so there is nothing that could override, and global is + // the whole answer. + Ok(id) if id.is_empty() => { let settings = db::queries::get_settings(&db).ok()?; NodeRpcClient::from_settings(&settings) } + Ok(profile_id) => NodeRpcClient::for_profile(&db, &profile_id).ok()?, + Err(_) => return None, } }; client @@ -342,6 +353,11 @@ pub async fn node_status(state: State<'_, AppState>) -> Result) -> Result bool { + // Peer/network socket errors are routine during sync — never fatal. + if line.contains("(net)") || line.contains("(peer)") { + return false; + } + // hsd's own error-level log lines, plus the well-known fatal shapes: + // - "[error]" level entries + // - "Cannot retroactively enable … indexing" (index mismatch) + // - address-in-use failures (another node already on the port) + // - an uncaught error/exception surfacing on startup + line.contains("[error]") + || line.contains("Cannot ") + || line.contains("EADDRINUSE") + || line.contains("already in use") + || line.contains("address in use") + || line.contains("Uncaught") + || line.contains("uncaught exception") + || line.contains("cannot open") + || line.contains("Cannot open") +} + /// If `/namehold-hsd.log` records a startup failure, return /// `(human_reason, is_index_mismatch)`. `None` when there's no log or it doesn't /// look like an error. The index-mismatch case (hsd can't retro-enable an index) @@ -370,28 +422,6 @@ pub(crate) fn node_start_error(data_dir: &str) -> Option<(String, bool)> { // Treating any "error" substring as a startup failure cries wolf over a // healthy node that is mid-rescan (its RPC simply hasn't come up yet). // Only lines that signal a real, fatal startup problem count. - let is_fatal_startup_line = |line: &str| -> bool { - // Peer/network socket errors are routine during sync — never fatal. - let networky = line.contains("(net)") || line.contains("(peer)"); - if networky { - return false; - } - // hsd's own error-level log lines, plus the well-known fatal shapes: - // - "[error]" level entries - // - "Cannot retroactively enable … indexing" (index mismatch) - // - address-in-use / bind failures (another node already on the port) - // - an uncaught error/exception surfacing on startup - line.contains("[error]") - || line.contains("Cannot ") - || line.contains("EADDRINUSE") - || line.contains("bind") - || line.contains("already in use") - || line.contains("address in use") - || line.contains("Uncaught") - || line.contains("uncaught exception") - || line.contains("cannot open") - || line.contains("Cannot open") - }; if !body.lines().any(is_fatal_startup_line) { return None; } @@ -461,39 +491,55 @@ pub async fn start_hsd(state: State<'_, AppState>) -> Result global -> hsd.conf) so the node + // we start and the node we talk to agree. Reading global settings here + // handed a profile with its own key a node started with somebody else's. + // `node_mode` stays global: it is not part of the ADR-001 tuple. + // + // The same resolution decides whether realign may touch the URL, so both + // are taken under one lock. let (api_key, node_mode) = { let db = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; + let profile_id = db::queries::get_active_profile_id(&db)?; + let effective = + crate::noncustodial::node_config::effective_node_config_for_profile(&db, &profile_id)?; let settings = db::queries::get_settings(&db)?; - let api_key = crate::noncustodial::rpc::resolve_node_api_key(&settings); let node_mode = crate::noncustodial::rpc::resolve_node_mode(&settings); - (api_key, node_mode) - }; - // hsd will listen on this network's RPC port, but `node_rpc_url` keeps - // whatever was seeded — the mainnet 12037. Left alone, the wallet starts a - // regtest node and then talks to a port nothing is listening on. Realign a - // stale loopback default now; a custom port or a remote host is left as the - // user set it (see `realign_loopback_rpc_url`). - { - let db = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; - let current = db::queries::get_settings(&db)? - .get("node_rpc_url") - .cloned() - .unwrap_or_default(); - if let Some(fixed) = crate::noncustodial::rpc::realign_loopback_rpc_url(¤t, network) { - db::queries::set_setting(&db, "node_rpc_url", &fixed)?; + // hsd will listen on this network's RPC port, but a globally seeded + // `node_rpc_url` keeps whatever it was given — the mainnet 12037. Left + // alone, the wallet starts a regtest node and then talks to a port + // nothing is listening on. Realign a stale loopback default now; a + // custom port or a remote host is left as the user set it (see + // `realign_loopback_rpc_url`). + // + // ADR-001 (Interaction with N11): a profile that supplies its own URL + // has made an explicit choice, and rewriting it — even a "stale + // default" — is the silent abandonment ADR-002 refuses. Realign + // therefore applies only to a URL that came from the global fallback, + // and it writes back to the global setting it came from. + if !effective.url_from_override { + if let Some(fixed) = + crate::noncustodial::rpc::realign_loopback_rpc_url(&effective.node_rpc_url, network) + { + db::queries::set_setting(&db, "node_rpc_url", &fixed)?; + } } - } + (effective.node_rpc_api_key, node_mode) + }; std::fs::create_dir_all(&data_dir) .map_err(|e| AppError::Other(format!("cannot create data dir {data_dir}: {e}")))?; @@ -694,19 +740,20 @@ pub async fn stop_hsd(state: State<'_, AppState>) -> Result<(), AppError> { // answering to stop. Best-effort: if nothing's reachable, that's fine. // Per-profile node override routing (ADR-001): stop the node the active // profile is actually pointed at (per-profile override -> global settings - // -> built-in default). Fall back to global settings when there is no - // active profile or its config is missing/misconfigured. + // -> built-in default). With no active profile there is no override to + // honour; a profile whose own config will not resolve is returned as the + // configuration error it is, because "stop the node" aimed at the wrong + // endpoint can stop somebody else's. let client = { let db = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; - match db::queries::get_active_profile_id(&db) { - Ok(profile_id) => NodeRpcClient::for_profile(&db, &profile_id).unwrap_or_else(|_| { - let settings = db::queries::get_settings(&db).unwrap_or_default(); - NodeRpcClient::from_settings(&settings) - }), - Err(_) => { + match db::queries::get_active_profile_id(&db)? { + // No active profile — the id is stored as an empty string, not an + // error — so there is nothing that could override. + id if id.is_empty() => { let settings = db::queries::get_settings(&db)?; NodeRpcClient::from_settings(&settings) } + profile_id => NodeRpcClient::for_profile(&db, &profile_id)?, } }; let _ = client.stop().await; @@ -927,8 +974,20 @@ pub async fn resync_hsd_chain(state: State<'_, AppState>) -> Result, /// Peers' best header height, when the node exposes it. pub headers: Option, - /// True when the node is at the chain tip per `chain_synced` (progress - /// ≥ 0.9999, else `blocks >= headers`; unknown → false for a first-contact - /// probe). The UI uses it to say "connected, but still syncing". + /// True when the node is at the chain tip per `chain_synced`: applied + /// blocks have caught up to the best header, with `verificationprogress` + /// only corroborating (unknown → false for a first-contact probe). The UI + /// uses it to say "connected, but still syncing". pub synced: bool, /// Network reported by the node: "main" / "testnet" / "regtest" / "simnet". pub network: Option, diff --git a/src-tauri/src/commands/node_readiness.rs b/src-tauri/src/commands/node_readiness.rs new file mode 100644 index 00000000..8c83882f --- /dev/null +++ b/src-tauri/src/commands/node_readiness.rs @@ -0,0 +1,282 @@ +//! Whether the node is good enough to be believed, and how high the chain is. +//! +//! One question with several callers and several amounts of context: a Tauri +//! `State`, a settings map, a profile id and a DB path, or an injected client. +//! They all end at [`node_tip_height_if_synced_with_client`], so "synced" means +//! the same thing to the read gate, the background sync, the chain scanner and +//! the watched-name daemon — and a node on the wrong chain is refused by all of +//! them rather than by whichever happened to check. +//! +//! [`estimate_persisted_height`] is the other half: what to believe about the +//! chain height when no node answers. It lives here because it is the fallback +//! of the same question, and because the callers that need one usually need +//! both. +//! +//! This is a dedicated helper module rather than more surface on +//! `commands::read`, which CODING_STANDARDS names as a legacy shared layer not +//! to add to. + +use tauri::State; + +use crate::db::queries; +use rusqlite::OptionalExtension; + +use crate::error::AppError; +use crate::AppState; + +/// Check if the local hsd node is connected AND fully synced, making local +/// cached data the preferred read source. Returns `true` when the node RPC +/// answers and the chain is caught up per `rpc::chain_synced` (the tip +/// decides, `verificationprogress` corroborates). +/// +/// In SPV mode, always returns `false` — SPV nodes don't have `--index-address` +/// and can't serve UTXO queries, so all reads must go through the explorer. +pub(crate) async fn is_node_ready_for_local_reads(state: &State<'_, AppState>) -> bool { + // SPV mode: node is never authoritative for reads. + let (node_mode, expected_network) = { + let db = match state.db.lock() { + Ok(db) => db, + Err(_) => return false, + }; + let settings = match crate::db::queries::get_settings(&db) { + Ok(s) => s, + Err(_) => return false, + }; + let mode = crate::noncustodial::rpc::resolve_node_mode(&settings); + // Resolve the active profile's network so we can reject a node on a + // different chain (e.g. regtest node vs mainnet wallet). + // A DB failure here degrades to "no network to compare" — the read + // gate is a routing decision, not a security boundary, and the SPV / + // sync gates below still apply. A caller that must not proceed on an + // unknown network propagates the error instead of degrading. + let net = queries::get_active_profile_network(&db).ok().flatten(); + (mode, net) + }; + if node_mode.is_spv() { + return false; + } + node_tip_height_if_synced_for_network(state, expected_network.as_deref()) + .await + .is_some() +} + +/// Like [`node_tip_height_if_synced`] but with an explicitly supplied +/// `expected_network`, for the one caller that has already resolved it +/// ([`is_node_ready_for_local_reads`]). Rejects the node when its reported +/// `chain` disagrees (e.g. a regtest node answering for a mainnet wallet). +/// `None` means "no network to compare" — see +/// [`node_tip_height_if_synced_with_client`] for why that is permissive. +pub(crate) async fn node_tip_height_if_synced_for_network( + state: &State<'_, AppState>, + expected_network: Option<&str>, +) -> Option { + let settings = { + let db = state.db.lock().ok()?; + crate::db::queries::get_settings(&db).ok()? + }; + node_tip_height_if_synced_from_settings_with_network(&settings, expected_network).await +} + +/// The live node tip height, but ONLY when the node is connected, fully synced, +/// AND reporting the same chain as the active profile. `None` when the node is +/// unreachable, catching up, or on another network. +/// +/// The expected network is resolved here rather than taken as an argument: +/// every `State`-based caller wants the active profile's chain, and a helper +/// that could be called without one is exactly how the cross-chain reads this +/// guard exists to prevent got in. Callers outside a `State` context use +/// [`node_tip_height_if_synced_from_settings_with_network`], which makes the +/// expected network an explicit argument they cannot forget. +pub(crate) async fn node_tip_height_if_synced(state: &State<'_, AppState>) -> Option { + // A network this cannot read is a node it cannot vouch for: a DB failure + // answers "not synced", not "nothing to compare". Only a genuinely absent + // active profile (onboarding) skips the chain comparison. + let expected_network = { + let db = state.db.lock().ok()?; + queries::get_active_profile_network(&db).ok()? + }; + node_tip_height_if_synced_for_network(state, expected_network.as_deref()).await +} + +/// Resolve the node's tip height from a settings map, returning `None` unless +/// the node is reachable and fully synced — and additionally rejecting when +/// its reported `chain` disagrees with `expected_network`. Set `expected_network` to the active profile's stored +/// network string — the schema allows only `"mainnet"`, `"testnet"` and +/// `"regtest"`; `"main"` and `"simnet"` are accepted defensively by the +/// comparison. Leave it `None` to skip the network check. +/// +/// This is the guard that prevents a regtest node from being treated as +/// authoritative for a mainnet wallet (or any other cross-network mismatch). +/// The comparison normalizes both sides through +/// [`crate::noncustodial::network::network_name_matches`] so +/// `"mainnet"` (profile) and `"main"` (hsd) count as equal. +pub(crate) async fn node_tip_height_if_synced_from_settings_with_network( + settings: &std::collections::HashMap, + expected_network: Option<&str>, +) -> Option { + let client = crate::noncustodial::rpc::NodeRpcClient::from_settings(settings); + node_tip_height_if_synced_with_client(&client, expected_network).await +} + +/// Per-profile readiness probe: the live node tip height, but ONLY when the node +/// is connected, fully synced, AND reporting the same chain as the profile. +/// Returns None when the node is unreachable, catching up, on another network, +/// or the profile doesn't exist. +/// +/// Per-profile node override routing (ADR-001): if the profile has a per-profile +/// override, it takes precedence; otherwise falls back to global settings; otherwise +/// uses the built-in default. This is the readiness probe for background daemons +/// (chain scanner, watched-name daemon) that operate on behalf of a specific profile. +pub(crate) async fn node_tip_height_if_synced_from_profile_with_network( + db_path: &str, + profile_id: &str, + expected_network: Option<&str>, +) -> Option { + let conn = match crate::db::connection::open(std::path::Path::new(db_path)) { + Ok(c) => c, + Err(_) => return None, + }; + // ADR-001: a profile whose node config will not resolve is a configuration + // error for that profile, not a fallback to global. This gate answers + // "is this profile's node authoritative?", and global's node is not an + // answer to that question — it may be another chain entirely. + let client = crate::noncustodial::rpc::NodeRpcClient::for_profile(&conn, profile_id).ok()?; + node_tip_height_if_synced_with_client(&client, expected_network).await +} + +/// The client-injected core of [`node_tip_height_if_synced_from_settings_with_network`]. +/// All the sync-progress + network-match logic lives here so it can be unit +/// tested against a `MockNodeRpc` without a live node. The settings-based +/// wrappers construct the real `NodeRpcClient` and delegate here. +pub(crate) async fn node_tip_height_if_synced_with_client( + client: &dyn crate::noncustodial::node_rpc::NodeRpc, + expected_network: Option<&str>, +) -> Option { + let info = client.get_blockchain_info().await.ok()?; + // Reject the node only on a POSITIVE mismatch. `network_check` returns + // `None` when either side is unknown (no profile, or a node that doesn't + // report `chain` — older builds); we conservatively allow that, and the + // SPV gate and other checks still apply. + if crate::noncustodial::network::network_check(expected_network, info.chain.as_deref()) + == Some(false) + { + return None; + } + // Connected — now check if synced. No sync metadata at all (e.g. regtest + // with a single miner) counts as synced. + info.is_synced(/* assume_when_unknown */ true) + .then_some(info.blocks) +} + +/// Settings-based readiness gate: `true` when the local node is connected, fully +/// synced, AND reporting `expected_network`. Mirrors +/// [`is_node_ready_for_local_reads`] for callers that only have settings/a DB +/// connection (the background sync thread, the chain scanner, the watched-name +/// daemon). Pass the active profile's stored network string; `None` skips the +/// comparison and should only be used where no profile exists. +pub async fn node_ready_from_settings( + settings: &std::collections::HashMap, + expected_network: Option<&str>, +) -> bool { + node_tip_height_if_synced_from_settings_with_network(settings, expected_network) + .await + .is_some() +} + +/// Per-profile readiness gate: true when the node is connected, fully synced, +/// AND reporting the profile's network. Mirrors node_ready_from_settings for +/// callers that have a profile ID and a DB path (background daemons). +/// Returns false when the profile doesn't exist or the node is unreachable. +pub async fn node_ready_from_profile( + db_path: &str, + profile_id: &str, + expected_network: Option<&str>, +) -> bool { + node_tip_height_if_synced_from_profile_with_network(db_path, profile_id, expected_network) + .await + .is_some() +} + +/// Best persisted estimate of the current chain height when no synced node is +/// available, extrapolated to "now" by elapsed wall time (~10-minute blocks). +/// Extrapolation matters for safety: a stale snapshot UNDERestimates the +/// height and therefore INFLATES days-until-expiry — the dangerous direction. +/// +/// Candidates (max wins): +/// * per-name explorer/node stats snapshots persisted in +/// `tracked_name_states.raw_json` — `renewalPeriodEnd - blocksUntilExpire` +/// is the chain height the stats were computed at, aged by `updated_at`; +/// * `wallet_profiles.last_synced_height`, aged by `last_synced_at`. +pub(crate) fn estimate_persisted_height( + conn: &rusqlite::Connection, + profile_id: &str, +) -> Result, AppError> { + // Ageing a stored height by wall clock assumes blocks arrive on a schedule. + // They do on main and testnet; on regtest and simnet they are mined on + // demand, so the same arithmetic invents six blocks an idle hour never + // produced and every renewal countdown drifts. There, report the stored + // height as-is: stale but true. + let ages_by_wall_clock = + crate::commands::active_profile::profile_network_from_conn(conn, profile_id)? + .has_wall_clock_block_timing(); + let age = |elapsed: i64| { + if ages_by_wall_clock { + elapsed.max(0) + } else { + 0 + } + }; + + let mut best: Option = None; + let mut consider = |h: Option| { + if let Some(h) = h { + best = Some(best.map_or(h, |b| b.max(h))); + } + }; + + // Per-name stats snapshots. raw_json is either the explorer HsdName shape + // (stats at the root) or the node getnameinfo result ({"info": {...}}). + let mut stmt = conn.prepare( + "SELECT raw_json, + CAST((strftime('%s','now') - strftime('%s', updated_at)) / 600 AS INTEGER) + FROM tracked_name_states + WHERE wallet_profile_id = ?1 AND raw_json IS NOT NULL", + )?; + let rows = stmt.query_map(rusqlite::params![profile_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + for row in rows { + let (raw, elapsed_blocks) = row?; + let Ok(v) = serde_json::from_str::(&raw) else { + continue; + }; + let info = match v.get("info") { + Some(i) if !i.is_null() => i, + _ => &v, + }; + let Some(stats) = info.get("stats").filter(|s| !s.is_null()) else { + continue; + }; + let end = stats.get("renewalPeriodEnd").and_then(|x| x.as_i64()); + let until = stats.get("blocksUntilExpire").and_then(|x| x.as_i64()); + if let (Some(end), Some(until)) = (end, until) { + consider(Some(end - until + age(elapsed_blocks))); + } + } + + // Last node-synced height (stale, but still a floor), aged the same way. + let profile_snapshot: Option<(Option, i64)> = conn + .query_row( + "SELECT last_synced_height, + CAST((strftime('%s','now') - strftime('%s', COALESCE(last_synced_at, datetime('now')))) / 600 AS INTEGER) + FROM wallet_profiles WHERE id = ?1", + rusqlite::params![profile_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((Some(h), elapsed_blocks)) = profile_snapshot { + consider(Some(h + age(elapsed_blocks))); + } + + Ok(best) +} diff --git a/src-tauri/src/commands/paid_swaps.rs b/src-tauri/src/commands/paid_swaps.rs index c490522d..df517294 100644 --- a/src-tauri/src/commands/paid_swaps.rs +++ b/src-tauri/src/commands/paid_swaps.rs @@ -304,17 +304,18 @@ pub async fn claim_paid_transfer( // Per-profile node override routing (ADR-001): a paid swap is verified // against a node on the active profile's network. Use the active // profile's effective node config (per-profile override -> global - // settings -> built-in default). Fall back to global settings when - // there is no active profile or its config is missing/misconfigured. - let client = match queries::get_active_profile_id(&db) { - Ok(profile_id) => NodeRpcClient::for_profile(&db, &profile_id).unwrap_or_else(|_| { - let settings = queries::get_settings(&db).unwrap_or_default(); - NodeRpcClient::from_settings(&settings) - }), - Err(_) => { + // settings -> built-in default). A profile whose config will not + // resolve is returned as the configuration error it is: this decides + // whether a payment arrived, and asking a node on another chain + // answers a different question. + let client = match queries::get_active_profile_id(&db)? { + // No active profile — the id is stored as an empty string, not an + // error — so there is nothing that could override. + id if id.is_empty() => { let settings = queries::get_settings(&db)?; NodeRpcClient::from_settings(&settings) } + profile_id => NodeRpcClient::for_profile(&db, &profile_id)?, }; (offer, client) }; diff --git a/src-tauri/src/commands/read.rs b/src-tauri/src/commands/read.rs index 9e984eaf..8ce8a00b 100644 --- a/src-tauri/src/commands/read.rs +++ b/src-tauri/src/commands/read.rs @@ -21,11 +21,11 @@ //! dead code. Any future test claiming to cover that branch is actually hitting //! the active-profile fallback. +use crate::commands::active_profile::profile_network_from_conn; use crate::db::queries; use crate::error::AppError; use crate::providers::hnsfans::HnsFansClient; use crate::AppState; -use rusqlite::OptionalExtension; use serde::Serialize; use std::collections::HashSet; use std::time::Duration; @@ -74,147 +74,6 @@ pub(crate) fn resolve_profile( active_profile(state) } -/// Check if the local hsd node is connected AND fully synced, making local -/// cached data the preferred read source. Returns `true` when the node RPC -/// answers and the chain is caught up (height ≥ headers, or progress ≥ 0.9999). -/// -/// In SPV mode, always returns `false` — SPV nodes don't have `--index-address` -/// and can't serve UTXO queries, so all reads must go through the explorer. -pub(crate) async fn is_node_ready_for_local_reads(state: &State<'_, AppState>) -> bool { - // SPV mode: node is never authoritative for reads. - let (node_mode, expected_network) = { - let db = match state.db.lock() { - Ok(db) => db, - Err(_) => return false, - }; - let settings = match crate::db::queries::get_settings(&db) { - Ok(s) => s, - Err(_) => return false, - }; - let mode = crate::noncustodial::rpc::resolve_node_mode(&settings); - // Resolve the active profile's network so we can reject a node on a - // different chain (e.g. regtest node vs mainnet wallet). - // A DB failure here degrades to "no network to compare" — the read - // gate is a routing decision, not a security boundary, and the SPV / - // sync gates below still apply. The connection probe (commands/node.rs) - // propagates the same error instead. - let net = queries::get_active_profile_network(&db).ok().flatten(); - (mode, net) - }; - if node_mode.is_spv() { - return false; - } - node_tip_height_if_synced_for_network(state, expected_network.as_deref()) - .await - .is_some() -} - -/// Like [`node_tip_height_if_synced`] but with an explicitly supplied -/// `expected_network`, for the one caller that has already resolved it -/// ([`is_node_ready_for_local_reads`]). Rejects the node when its reported -/// `chain` disagrees (e.g. a regtest node answering for a mainnet wallet). -/// `None` means "no network to compare" — see -/// [`node_tip_height_if_synced_with_client`] for why that is permissive. -pub(crate) async fn node_tip_height_if_synced_for_network( - state: &State<'_, AppState>, - expected_network: Option<&str>, -) -> Option { - let settings = { - let db = state.db.lock().ok()?; - crate::db::queries::get_settings(&db).ok()? - }; - node_tip_height_if_synced_from_settings_with_network(&settings, expected_network).await -} - -/// The live node tip height, but ONLY when the node is connected, fully synced, -/// AND reporting the same chain as the active profile. `None` when the node is -/// unreachable, catching up, or on another network. -/// -/// The expected network is resolved here rather than taken as an argument: -/// every `State`-based caller wants the active profile's chain, and a helper -/// that could be called without one is exactly how the cross-chain reads this -/// guard exists to prevent got in. Callers outside a `State` context use -/// [`node_tip_height_if_synced_from_settings_with_network`], which makes the -/// expected network an explicit argument they cannot forget. -pub(crate) async fn node_tip_height_if_synced(state: &State<'_, AppState>) -> Option { - let expected_network = { - let db = state.db.lock().ok()?; - queries::get_active_profile_network(&db).ok().flatten() - }; - node_tip_height_if_synced_for_network(state, expected_network.as_deref()).await -} - -/// Same as [`node_tip_height_if_synced_from_settings`], but additionally -/// rejects (returns `None`) when the node's reported `chain` disagrees with -/// `expected_network`. Set `expected_network` to the active profile's stored -/// network string — the schema allows only `"mainnet"`, `"testnet"` and -/// `"regtest"`; `"main"` and `"simnet"` are accepted defensively by the -/// comparison. Leave it `None` to skip the network check. -/// -/// This is the guard that prevents a regtest node from being treated as -/// authoritative for a mainnet wallet (or any other cross-network mismatch). -/// The comparison normalizes both sides through -/// [`crate::noncustodial::network::network_name_matches`] so -/// `"mainnet"` (profile) and `"main"` (hsd) count as equal. -pub(crate) async fn node_tip_height_if_synced_from_settings_with_network( - settings: &std::collections::HashMap, - expected_network: Option<&str>, -) -> Option { - let client = crate::noncustodial::rpc::NodeRpcClient::from_settings(settings); - node_tip_height_if_synced_with_client(&client, expected_network).await -} - -/// Per-profile readiness probe: the live node tip height, but ONLY when the node -/// is connected, fully synced, AND reporting the same chain as the profile. -/// Returns None when the node is unreachable, catching up, on another network, -/// or the profile doesn't exist. -/// -/// Per-profile node override routing (ADR-001): if the profile has a per-profile -/// override, it takes precedence; otherwise falls back to global settings; otherwise -/// uses the built-in default. This is the readiness probe for background daemons -/// (chain scanner, watched-name daemon) that operate on behalf of a specific profile. -pub(crate) async fn node_tip_height_if_synced_from_profile_with_network( - db_path: &str, - profile_id: &str, - expected_network: Option<&str>, -) -> Option { - let conn = match crate::db::connection::open(std::path::Path::new(db_path)) { - Ok(c) => c, - Err(_) => return None, - }; - let client = crate::noncustodial::rpc::NodeRpcClient::for_profile(&conn, profile_id) - .unwrap_or_else(|_| { - // Fallback to global settings if profile config is missing or misconfigured. - let settings = queries::get_settings(&conn).unwrap_or_default(); - crate::noncustodial::rpc::NodeRpcClient::from_settings(&settings) - }); - node_tip_height_if_synced_with_client(&client, expected_network).await -} - -/// The client-injected core of [`node_tip_height_if_synced_from_settings_with_network`]. -/// All the sync-progress + network-match logic lives here so it can be unit -/// tested against a `MockNodeRpc` without a live node. The settings-based -/// wrappers construct the real `NodeRpcClient` and delegate here. -pub(crate) async fn node_tip_height_if_synced_with_client( - client: &dyn crate::noncustodial::node_rpc::NodeRpc, - expected_network: Option<&str>, -) -> Option { - let info = client.get_blockchain_info().await.ok()?; - // Reject the node only on a POSITIVE mismatch. `network_check` returns - // `None` when either side is unknown (no profile, or a node that doesn't - // report `chain` — older builds); we conservatively allow that, and the - // SPV gate and other checks still apply. - if crate::noncustodial::network::network_check(expected_network, info.chain.as_deref()) - == Some(false) - { - return None; - } - // Connected — now check if synced. No sync metadata at all (e.g. regtest - // with a single miner) counts as synced. - info.is_synced(/* assume_when_unknown */ true) - .then_some(info.blocks) -} - /// Client-injected RPC phase of owned-name discovery. Resolves each /// `WalletNameHash` → name via `getnamebyhash` (falling back to the coin's /// `raw_name_hex`), then fetches `getnameinfo` for each resolved name. @@ -327,35 +186,6 @@ pub(crate) async fn resolve_name_ownership_with_client( }) } -/// Settings-based readiness gate: `true` when the local node is connected, fully -/// synced, AND reporting `expected_network`. Mirrors -/// [`is_node_ready_for_local_reads`] for callers that only have settings/a DB -/// connection (the background sync thread, the chain scanner, the watched-name -/// daemon). Pass the active profile's stored network string; `None` skips the -/// comparison and should only be used where no profile exists. -pub async fn node_ready_from_settings( - settings: &std::collections::HashMap, - expected_network: Option<&str>, -) -> bool { - node_tip_height_if_synced_from_settings_with_network(settings, expected_network) - .await - .is_some() -} - -/// Per-profile readiness gate: true when the node is connected, fully synced, -/// AND reporting the profile's network. Mirrors node_ready_from_settings for -/// callers that have a profile ID and a DB path (background daemons). -/// Returns false when the profile doesn't exist or the node is unreachable. -pub async fn node_ready_from_profile( - db_path: &str, - profile_id: &str, - expected_network: Option<&str>, -) -> bool { - node_tip_height_if_synced_from_profile_with_network(db_path, profile_id, expected_network) - .await - .is_some() -} - /// HNSFans explorer client from settings + the active profile's network. /// Thin wrapper kept for call-site brevity — the actual construction and the /// network gate live in [`crate::providers::explorer_client_from_settings`] @@ -374,12 +204,7 @@ fn explorer_client( /// unavailable for the active profile's network (G2). Concrete and actionable: /// it names the missing setting rather than degrading to empty/mainnet data. fn explorer_unavailable_error() -> AppError { - AppError::Other( - "No explorer is available for this network. The node is not synced \ - and no 'explorer_api_url' is configured — set one in Settings, or \ - wait for the local node to finish syncing." - .to_string(), - ) + AppError::Other(crate::providers::EXPLORER_UNAVAILABLE.to_string()) } /// Node-only owned-name discovery for [`discover_owned_names`]. Resolves the @@ -526,16 +351,16 @@ pub async fn read_balance( }; // Prefer local cache when the node is connected and synced. - if is_node_ready_for_local_reads(&state).await { + if crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; - return queries::read_cached_balance(&conn, &id, profile_network(&conn, &id)?); + return queries::read_cached_balance(&conn, &id, profile_network_from_conn(&conn, &id)?); } // Explorer fallback. let (client_opt, mut addrs) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let settings = queries::get_settings(&conn)?; - let network = profile_network(&conn, &id)?; + let network = profile_network_from_conn(&conn, &id)?; ( explorer_client(&settings, network), queries::get_profile_addresses(&conn, &id)?, @@ -548,7 +373,11 @@ pub async fn read_balance( Some(c) => c, None => { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; - return queries::read_cached_balance(&conn, &id, profile_network(&conn, &id)?); + return queries::read_cached_balance( + &conn, + &id, + profile_network_from_conn(&conn, &id)?, + ); } }; // Auto-provision derived addresses if none exist yet, so the explorer @@ -604,22 +433,10 @@ pub async fn read_balance( } } let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; - let network = profile_network(&conn, &id)?; + let network = profile_network_from_conn(&conn, &id)?; queries::read_cached_balance(&conn, &id, network) } -/// The `Network` of one profile, for the cached read model. Errors rather than -/// defaulting: a balance computed with the wrong coinbase maturity would report -/// funds as spendable that coin selection refuses. -fn profile_network( - conn: &rusqlite::Connection, - profile_id: &str, -) -> Result { - let profile = queries::get_wallet_profile(conn, profile_id)? - .ok_or_else(|| AppError::NotFound(format!("wallet profile {profile_id}")))?; - crate::noncustodial::derivation::network_from_profile(&profile.network) -} - /// Names this wallet actually OWNS on-chain — the union of node-free discovered /// owners ([`discover_owned_names`]) and node-synced owners. Both are pure DB /// reads, so this is instant and never includes the migration *inventory* @@ -647,7 +464,7 @@ pub async fn read_names( }; // Check node readiness BEFORE acquiring the DB lock so we don't hold // MutexGuard across the async RPC probe (MutexGuard is !Send). - let local_ready = is_node_ready_for_local_reads(&state).await; + let local_ready = crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await; let out = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; @@ -744,14 +561,14 @@ pub async fn discover_owned_names( // us the nameHash, resolved to a name via `getnamebyhash` (or the paired // OPEN/BID/FINALIZE covenant's rawName). This is the "post-sync workaround // retired" path described in the Feature 3 plan. - if is_node_ready_for_local_reads(&state).await { + if crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await { return discover_owned_names_via_node(&state, &id).await; } let (client_opt, addrs) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let settings = queries::get_settings(&conn)?; - let network = profile_network(&conn, &id)?; + let network = profile_network_from_conn(&conn, &id)?; ( explorer_client(&settings, network), queries::get_profile_addresses(&conn, &id)?, @@ -882,8 +699,14 @@ pub async fn read_name_info( let (explorer_opt, node_opt) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let settings = queries::get_settings(&conn)?; - let network = crate::commands::active_profile::active_profile_network_from_conn(&conn); - let explorer_opt = explorer_client(&settings, network); + // A network that cannot be read is unknown, not mainnet. `Network` + // defaults to `Main`, so the defaulting reader turned "we could not + // tell" into a live request to the mainnet explorer for a wallet that + // may be on another chain — the cross-network read G2 exists to close, + // and the opposite of what the no-explorer path below already does. + let explorer_opt = + crate::commands::active_profile::active_profile_network_opt_from_conn(&conn) + .and_then(|network| explorer_client(&settings, network)); let node_opt = match queries::get_active_profile_id(&conn) { Ok(id) if !id.is_empty() => Some(crate::noncustodial::rpc::NodeRpcClient::for_profile( &conn, &id, @@ -900,7 +723,7 @@ pub async fn read_name_info( // `getnameinfo` returns `{ info: { name, state, stats:{…phase…} } }` // (or null `info` for a name that has never been touched on-chain). if let Some(node) = node_opt.as_ref() { - if is_node_ready_for_local_reads(&state).await { + if crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await { if let Some(shaped) = read_name_info_node_with_client(node, &name).await { return Ok(serde_json::to_value(&shaped)?); } @@ -1182,17 +1005,26 @@ pub async fn read_name_bids( // indexed past the name's auction height, serve bids from the local // `name_bid_outpoints` table — no HNSFans call. Fall through to the // explorer when the scanner hasn't caught up yet. - if is_node_ready_for_local_reads(&state).await { + if crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await { let name_hash_hex = hex::encode(crate::noncustodial::names::hash_name(&name)?); let (indexed_bids, commitments, scanner_height, name_height) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; // Both the index and the cursor are network-keyed (028): a name // hashes identically on every chain, and a cursor from another - // chain says nothing about this one's coverage. - let network = - crate::commands::active_profile::active_profile_network_from_conn(&conn).as_str(); + // chain says nothing about this one's coverage. Which makes the + // defaulting reader the wrong one here too — an unknown network + // resolving to mainnet would read mainnet's cursor to decide + // whether this chain's index may be trusted. Unknown falls through + // to the explorer path below instead, as an uncovered scanner + // already does. + let network = profile_network_from_conn(&conn, &id).ok(); let comms = queries::list_bid_commitments(&conn, &id)?; - let cursor_h = crate::commands::chain_scan::scan_cursor_height(&conn, network); + // With no network there is no cursor to read: leaving the coverage + // at 0 makes `scanner_covers` false, which is the path an + // un-caught-up scanner already takes. + let cursor_h = network + .map(|n| crate::commands::chain_scan::scan_cursor_height(&conn, n.as_str())) + .unwrap_or(0); // The OPEN height of the name's CURRENT auction. `upsert_name_state` // clears it when the node reports no auction, so `None` means the // name has none open right now — not merely that we haven't looked. @@ -1209,12 +1041,15 @@ pub async fn read_name_bids( // still indexed, and serving them is what made a name sitting at // "Waiting for Bidding" list bids from its previous auction. let indexed = match nh { - Some(start) => crate::commands::chain_scan::read_indexed_bids( - &conn, - network, - start, - &name_hash_hex, - )?, + Some(start) => match network { + Some(n) => crate::commands::chain_scan::read_indexed_bids( + &conn, + n.as_str(), + start, + &name_hash_hex, + )?, + None => Vec::new(), + }, None => Vec::new(), }; // Scope the commitments to this auction too (030). Otherwise the @@ -1223,11 +1058,7 @@ pub async fn read_name_bids( // modal reports those separately. let comms: Vec = comms .into_iter() - .filter(|c| match (nh, c.name_start_height) { - (Some(start), Some(placed)) => placed == start, - (Some(_), None) => true, - (None, _) => true, - }) + .filter(|c| c.belongs_to_auction(nh)) .collect(); (indexed, comms, cursor_h, nh) }; @@ -1247,11 +1078,13 @@ pub async fn read_name_bids( let (client_opt, commitments) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let settings = queries::get_settings(&conn)?; - let network = crate::commands::active_profile::active_profile_network_from_conn(&conn); - ( - explorer_client(&settings, network), - queries::list_bid_commitments(&conn, &id)?, - ) + // The network belongs to the profile this command was handed, which + // need not be the active one — and a profile whose network cannot be + // read leaves no explorer rather than defaulting to mainnet's. + let explorer_opt = profile_network_from_conn(&conn, &id) + .ok() + .and_then(|network| explorer_client(&settings, network)); + (explorer_opt, queries::list_bid_commitments(&conn, &id)?) }; // G2: no explorer for this network and the node hasn't indexed the name // yet — return empty bids (same shape as when the name has no bids) rather @@ -1301,8 +1134,10 @@ pub async fn get_resource( let (explorer_opt, node_opt) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let s = queries::get_settings(&conn)?; - let network = crate::commands::active_profile::active_profile_network_from_conn(&conn); - let explorer_opt = explorer_client(&s, network); + // Unknown is not mainnet — see the note on the same resolution above. + let explorer_opt = + crate::commands::active_profile::active_profile_network_opt_from_conn(&conn) + .and_then(|network| explorer_client(&s, network)); let node_opt = match queries::get_active_profile_id(&conn) { Ok(id) if !id.is_empty() => Some(crate::noncustodial::rpc::NodeRpcClient::for_profile( &conn, &id, @@ -1313,7 +1148,8 @@ pub async fn get_resource( }; // Records come from the node only; without a profile there's no node client // and thus no records — treat that like "node not ready". - let node_ready = node_opt.is_some() && is_node_ready_for_local_reads(&state).await; + let node_ready = node_opt.is_some() + && crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await; // Name info: try node first, then explorer. let info: serde_json::Value = if let (true, Some(node)) = (node_ready, node_opt.as_ref()) { @@ -1389,7 +1225,7 @@ pub async fn read_name_records( Some(id) => id, None => return Ok(empty_resource()), }; - if is_node_ready_for_local_reads(&state).await { + if crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await { // Build the node client from the resolved profile's *effective* config // (per-profile override -> global -> default; ADR-001) under a short // lock, then drop the guard BEFORE the async RPC call — the same @@ -1459,7 +1295,7 @@ pub async fn read_block_info( ) -> Result { // Node-only: without a synced local node there's nothing to read. Soft- // degrade to null so the modal can show a "requires synced node" hint. - if height < 0 || !is_node_ready_for_local_reads(&state).await { + if height < 0 || !crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await { return Ok(serde_json::Value::Null); } @@ -1539,7 +1375,9 @@ pub async fn read_tx_info( txid: String, ) -> Result { let txid = txid.trim().to_string(); - if txid.is_empty() || !is_node_ready_for_local_reads(&state).await { + if txid.is_empty() + || !crate::commands::node_readiness::is_node_ready_for_local_reads(&state).await + { return Ok(serde_json::Value::Null); } @@ -1859,91 +1697,6 @@ fn empty_renewals() -> RenewalsResponse { } } -/// Best persisted estimate of the current chain height when no synced node is -/// available, extrapolated to "now" by elapsed wall time (~10-minute blocks). -/// Extrapolation matters for safety: a stale snapshot UNDERestimates the -/// height and therefore INFLATES days-until-expiry — the dangerous direction. -/// -/// Candidates (max wins): -/// * per-name explorer/node stats snapshots persisted in -/// `tracked_name_states.raw_json` — `renewalPeriodEnd - blocksUntilExpire` -/// is the chain height the stats were computed at, aged by `updated_at`; -/// * `wallet_profiles.last_synced_height`, aged by `last_synced_at`. -pub(crate) fn estimate_persisted_height( - conn: &rusqlite::Connection, - profile_id: &str, -) -> Result, AppError> { - // Ageing a stored height by wall clock assumes blocks arrive on a schedule. - // They do on main and testnet; on regtest and simnet they are mined on - // demand, so the same arithmetic invents six blocks an idle hour never - // produced and every renewal countdown drifts. There, report the stored - // height as-is: stale but true. - let ages_by_wall_clock = queries::get_wallet_profile(conn, profile_id)? - .and_then(|p| crate::noncustodial::network::Network::from_str_opt(&p.network)) - .unwrap_or_default() - .has_wall_clock_block_timing(); - let age = |elapsed: i64| { - if ages_by_wall_clock { - elapsed.max(0) - } else { - 0 - } - }; - - let mut best: Option = None; - let mut consider = |h: Option| { - if let Some(h) = h { - best = Some(best.map_or(h, |b| b.max(h))); - } - }; - - // Per-name stats snapshots. raw_json is either the explorer HsdName shape - // (stats at the root) or the node getnameinfo result ({"info": {...}}). - let mut stmt = conn.prepare( - "SELECT raw_json, - CAST((strftime('%s','now') - strftime('%s', updated_at)) / 600 AS INTEGER) - FROM tracked_name_states - WHERE wallet_profile_id = ?1 AND raw_json IS NOT NULL", - )?; - let rows = stmt.query_map(rusqlite::params![profile_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) - })?; - for row in rows { - let (raw, elapsed_blocks) = row?; - let Ok(v) = serde_json::from_str::(&raw) else { - continue; - }; - let info = match v.get("info") { - Some(i) if !i.is_null() => i, - _ => &v, - }; - let Some(stats) = info.get("stats").filter(|s| !s.is_null()) else { - continue; - }; - let end = stats.get("renewalPeriodEnd").and_then(|x| x.as_i64()); - let until = stats.get("blocksUntilExpire").and_then(|x| x.as_i64()); - if let (Some(end), Some(until)) = (end, until) { - consider(Some(end - until + age(elapsed_blocks))); - } - } - - // Last node-synced height (stale, but still a floor), aged the same way. - let profile_snapshot: Option<(Option, i64)> = conn - .query_row( - "SELECT last_synced_height, - CAST((strftime('%s','now') - strftime('%s', COALESCE(last_synced_at, datetime('now')))) / 600 AS INTEGER) - FROM wallet_profiles WHERE id = ?1", - rusqlite::params![profile_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((Some(h), elapsed_blocks)) = profile_snapshot { - consider(Some(h + age(elapsed_blocks))); - } - - Ok(best) -} - /// Pure DB + math core of `read_renewals` (testable without Tauri state). /// /// `live_node_height` is the tip of a connected, fully synced node when one is @@ -1977,7 +1730,8 @@ pub(crate) fn compute_renewals( let (current_height, height_source) = match live_node_height { Some(h) => (Some(h), "node"), - None => match estimate_persisted_height(conn, profile_id)? { + None => match crate::commands::node_readiness::estimate_persisted_height(conn, profile_id)? + { Some(h) => (Some(h), "explorer"), None => (None, "unknown"), }, @@ -2120,7 +1874,7 @@ pub async fn read_renewals( None => return Ok(empty_renewals()), }; // Probe the node BEFORE taking the DB lock (the guard is !Send). - let live_height = node_tip_height_if_synced(&state).await; + let live_height = crate::commands::node_readiness::node_tip_height_if_synced(&state).await; let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; compute_renewals(&conn, &id, live_height) } @@ -2171,14 +1925,14 @@ pub async fn repair_owned_names(state: State<'_, AppState>) -> Result) -> Result` -//! webview) and IPC dispatch. `prompt_secure`, `secure_prompt_fetch`, -//! `secure_prompt_submit`, and `assert_owning_window` all open/manage/close Tauri -//! windows and await oneshot channels from frontend IPC calls — impossible to -//! exercise from a unit test without a full Tauri desktop runtime. The only -//! testable functions are `random_id` (pure RNG) and `push_test_answer` (test -//! helper). All real logic (secret handling, passphrase validation) is in the -//! callers (`commands::secure_wallet`) which ARE tested via the secure-window -//! flow in integration tests. This module is the plumbing. //! closed by the backend. //! //! The secret value (passphrase or mnemonic) only ever flows window <-> backend. //! It is never the return value of a React-invoked command. +//! +//! Coverage here is low and stays low: `prompt_secure`, `secure_prompt_fetch`, +//! `secure_prompt_submit` and `assert_owning_window` open, manage and close +//! Tauri windows and await oneshot channels fulfilled by frontend IPC, which a +//! unit test cannot drive without a full desktop runtime. Only `random_id` and +//! the `push_test_answer` helper are reachable. The logic worth testing — +//! secret handling and passphrase validation — lives in the callers +//! (`commands::secure_wallet`) and is covered through the secure-window +//! integration flow. This module is the plumbing. use std::collections::HashMap; use std::sync::Mutex; diff --git a/src-tauri/src/commands/secure_wallet.rs b/src-tauri/src/commands/secure_wallet.rs index c3eed2e0..85b21afd 100644 --- a/src-tauri/src/commands/secure_wallet.rs +++ b/src-tauri/src/commands/secure_wallet.rs @@ -103,7 +103,6 @@ pub(crate) fn account_xpub_from_seed( /// Derive + persist the initial receive/change address windows for a profile. /// Returns the first receive address (the profile's default receive address). -#[cfg_attr(coverage_nightly, coverage(off))] pub(crate) fn provision_addresses( conn: &rusqlite::Connection, profile_id: &str, diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index a40cda9d..4ad06c1b 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -458,20 +458,32 @@ pub async fn run_sync_steps( let node_authoritative = if node_mode.is_spv() { false } else { - // The network belongs to the profile being synced, not to whichever - // profile happens to be active: a node on another chain must never be - // authoritative for this one. - let snapshot = open_conn(db_path).ok().and_then(|c| { - let settings = queries::get_settings(&c).ok()?; - let network = queries::get_wallet_profile(&c, profile_id) - .ok() - .flatten() - .map(|p| p.network); - Some((settings, network)) - }); - match snapshot { - Some((s, network)) => { - crate::commands::read::node_ready_from_settings(&s, network.as_deref()).await + // Both the network and the node belong to the profile being synced, + // not to whichever profile happens to be active. The network, because + // a node on another chain must never be authoritative for this one. + // The node, because this profile may point at its own (ADR-001) — and + // the steps this gate governs already build their client per profile, + // so asking the global node whether it is caught up decided what to do + // with a node nobody was going to talk to. + // + // A network this step cannot read makes the node not authoritative, + // rather than authoritative without the comparison. Passing `None` + // here tells the readiness gate there is nothing to compare, which is + // right during onboarding and wrong for a profile that has a network + // and simply could not be read: it would let a node on another chain + // seed this profile's cache. The explorer path this falls back to is + // the conservative one and already exists. + match open_conn(db_path) + .ok() + .and_then(|c| queries::get_wallet_profile(&c, profile_id).ok().flatten()) + { + Some(p) => { + crate::commands::node_readiness::node_ready_from_profile( + db_path, + profile_id, + Some(&p.network), + ) + .await } None => false, } @@ -822,12 +834,14 @@ pub async fn repair_step_windowed( Err(_) => return, }; // G2: resolve the profile's network to gate the explorer availability. - let network = queries::get_wallet_profile(&conn, profile_id) - .ok() - .flatten() - .and_then(|p| crate::noncustodial::derivation::network_from_profile(&p.network).ok()) - .unwrap_or_default(); - let explorer_opt = crate::providers::explorer_client_from_settings(&settings, network); + // A network that cannot be read is unknown, not mainnet — guessing + // would point this step at another chain's explorer and write its + // answers into this profile's cache. + let explorer_opt = + crate::commands::active_profile::profile_network_opt_from_conn(&conn, profile_id) + .and_then(|network| { + crate::providers::explorer_client_from_settings(&settings, network) + }); let addrs = queries::get_profile_addresses(&conn, profile_id).unwrap_or_default(); // Total backlog counted once: the stable "/ N" denominator for progress. let total = @@ -841,10 +855,8 @@ pub async fn repair_step_windowed( Some(e) => e, None => { let mut s = status.lock().await; - s.errors.push( - "explorer unavailable for this network — configure explorer_api_url or wait for the local node to sync" - .to_string(), - ); + s.errors + .push(crate::providers::EXPLORER_UNAVAILABLE.to_string()); return; } }; @@ -1100,12 +1112,14 @@ pub async fn discover_step(status: &Arc>, db_path: &str, profi Err(_) => return, }; // G2: resolve the profile's network to gate the explorer availability. - let network = queries::get_wallet_profile(&conn, profile_id) - .ok() - .flatten() - .and_then(|p| crate::noncustodial::derivation::network_from_profile(&p.network).ok()) - .unwrap_or_default(); - let explorer_opt = crate::providers::explorer_client_from_settings(&settings, network); + // A network that cannot be read is unknown, not mainnet — guessing + // would point this step at another chain's explorer and write its + // answers into this profile's cache. + let explorer_opt = + crate::commands::active_profile::profile_network_opt_from_conn(&conn, profile_id) + .and_then(|network| { + crate::providers::explorer_client_from_settings(&settings, network) + }); let addrs = queries::get_profile_addresses(&conn, profile_id).unwrap_or_default(); (explorer_opt, addrs) }; @@ -1116,10 +1130,8 @@ pub async fn discover_step(status: &Arc>, db_path: &str, profi Some(e) => e, None => { let mut s = status.lock().await; - s.errors.push( - "explorer unavailable for this network — configure explorer_api_url or wait for the local node to sync" - .to_string(), - ); + s.errors + .push(crate::providers::EXPLORER_UNAVAILABLE.to_string()); return; } }; diff --git a/src-tauri/src/commands/sync_spv.rs b/src-tauri/src/commands/sync_spv.rs index a5773f1e..1c41e0ee 100644 --- a/src-tauri/src/commands/sync_spv.rs +++ b/src-tauri/src/commands/sync_spv.rs @@ -48,12 +48,15 @@ pub async fn sync_spv_step(db_path: &str, profile_id: &str) -> bool { Err(_) => return false, // lcov-excl-line }; // G2: resolve the profile's network (before dropping the connection) to - // gate the explorer availability below. - let network = queries::get_wallet_profile(&conn, profile_id) - .ok() - .flatten() - .and_then(|p| crate::noncustodial::derivation::network_from_profile(&p.network).ok()) - .unwrap_or_default(); + // gate the explorer availability below. A network that cannot be read is + // unknown, not mainnet — an SPV step that guessed would show the user a + // mainnet balance for a wallet that is not on mainnet. + let Some(network) = + crate::commands::active_profile::profile_network_opt_from_conn(&conn, profile_id) + else { + eprintln!("sync_spv_step: profile {profile_id} has no readable network"); + return false; + }; // Verify the SPV node is reachable. Resolve the profile's effective node // config (per-profile override -> global -> default) rather than reading // global settings directly for the node endpoint. The explorer factory diff --git a/src-tauri/src/commands/tx.rs b/src-tauri/src/commands/tx.rs index 31a89634..4c6a2e08 100644 --- a/src-tauri/src/commands/tx.rs +++ b/src-tauri/src/commands/tx.rs @@ -215,20 +215,25 @@ pub(crate) async fn resolve_fee_rate(state: &State<'_, AppState>, fee_rate: Opti } // 2) Ask the node for an estimate — same behavior as before. // Per-profile node override routing (ADR-001): if an active profile - // exists, use its effective node config; otherwise fall back to global. - let client = if let Some(id) = profile_id { - match state.db.lock() { - Ok(conn) => NodeRpcClient::for_profile(&conn, &id) - .unwrap_or_else(|_| NodeRpcClient::from_settings(&s)), - Err(_) => NodeRpcClient::from_settings(&s), - } - } else { - NodeRpcClient::from_settings(&s) + // exists, use its effective node config; with no profile at all + // there is no override to honour, so global is the whole answer. + // A profile whose config will not resolve falls through to the + // built-in rate rather than to global's node: a fee estimate from + // another chain's node is worse than no estimate. + let client = match profile_id.filter(|id| !id.is_empty()) { + Some(id) => match state.db.lock() { + Ok(conn) => NodeRpcClient::for_profile(&conn, &id).ok(), + Err(_) => None, + }, + None => Some(NodeRpcClient::from_settings(&s)), }; - client - .estimate_smart_fee(6) - .await - .unwrap_or(send::DEFAULT_FEE_RATE_PER_BYTE) + match client { + Some(c) => c + .estimate_smart_fee(6) + .await + .unwrap_or(send::DEFAULT_FEE_RATE_PER_BYTE), + None => send::DEFAULT_FEE_RATE_PER_BYTE, + } } None => send::DEFAULT_FEE_RATE_PER_BYTE, } @@ -246,7 +251,7 @@ pub async fn sync_wallet_state( ) -> Result { // 1. Snapshot addresses + settings under the lock, then release it before // any network I/O. - let (profile_id, profile_network, addresses, settings, client) = { + let (profile_id, profile_network, addresses, client) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let profile = match wallet_profile_id { Some(id) => db::queries::get_wallet_profile(&conn, &id)? @@ -285,13 +290,12 @@ pub async fn sync_wallet_state( } } } - let settings = db::queries::get_settings(&conn)?; // Per-profile node override routing (ADR-001): the sync must use the // profile's effective node config (override -> global -> default), not // the raw global settings. Build the client while the lock is held so // the effective-config resolver can read `profile_settings`. let client = NodeRpcClient::for_profile(&conn, &profile.id)?; - (profile.id, profile.network, addresses, settings, client) + (profile.id, profile.network, addresses, client) }; // Probe the node first. If it's unreachable, that's expected in explorer / @@ -308,11 +312,11 @@ pub async fn sync_wallet_state( info.chain.as_deref(), ) == Some(false) { - let reported = info.chain.as_deref().unwrap_or("unknown"); - return Err(AppError::InvalidInput(format!( - "node is on network '{reported}' but this wallet is '{profile_network}' — \ - refusing to sync; point the wallet at a {profile_network} node" - ))); + return Err(cross_network_refusal( + info.chain.as_deref(), + &profile_network, + &format!("refusing to sync; point the wallet at a {profile_network} node"), + )); } info.blocks } @@ -326,13 +330,13 @@ pub async fn sync_wallet_state( } }; - // 2. Fetch coins per address (network I/O, no lock held). - let node_url = settings - .get("node_rpc_url") - .map(|s| s.as_str()) - .unwrap_or("the configured node"); + // 2. Fetch coins per address (network I/O, no lock held). The URL named in + // any failure is the one this client resolved through the profile, not the + // global setting: they differ whenever the profile overrides it, and the + // global one would send the user to fix a node that was never asked. + let node_url = client.node_url().to_string(); let (all_coins, txs) = - fetch_wallet_coins_and_txs_with_client(&client, &addresses, node_url).await?; + fetch_wallet_coins_and_txs_with_client(&client, &addresses, &node_url).await?; // Balances below are split on coinbase maturity, which is per-network. let sync_network = derivation::network_from_profile(&profile_network)?; @@ -1268,7 +1272,6 @@ async fn sign_via_ledger( /// Convert pre-resolved `(output_index, name)` pairs into the /// [`OutputName`](crate::providers::ledger::parse_mode::OutputName) entries /// that the parse-mode builder expects. -#[cfg_attr(coverage_nightly, coverage(off))] fn output_names_from_pairs( pairs: &[(usize, String)], ) -> Vec { @@ -1329,8 +1332,8 @@ pub async fn sign_name_message( // during REVEAL that is our own REVEAL coin — hsd reports the highest // revealer as the owner long before anyone has won. Signing it would // produce a well-formed claim of ownership that every verifier - // resolves as false. Same rule as the ownership capabilities. - if coin.covenant_type < crate::noncustodial::sync::COV_REGISTER as i64 { + // resolves as false. + if !crate::noncustodial::covenants::is_registered_owner_covenant(Some(coin.covenant_type)) { return Err(AppError::InvalidInput(format!( "the name '{name}' is not registered yet — there is no ownership to prove" ))); @@ -1427,7 +1430,6 @@ pub(crate) async fn classify_broadcast_outcome_with_client( /// silently skipped — the tx cache is best-effort, not authoritative. /// /// Testable against a mock without an AppState. -#[cfg_attr(coverage_nightly, coverage(off))] pub(crate) async fn fetch_wallet_coins_and_txs_with_client( client: &dyn crate::noncustodial::node_rpc::NodeRpc, addresses: &[String], @@ -1531,15 +1533,28 @@ pub(crate) async fn broadcast_network_guard_with_client( if crate::noncustodial::network::network_check(Some(expected), info.chain.as_deref()) == Some(false) { - let reported = info.chain.as_deref().unwrap_or("unknown"); - return Err(AppError::InvalidInput(format!( - "node is on network '{reported}' but this wallet is '{expected}' — refusing to \ - broadcast; the transaction was not sent and the draft is unchanged" - ))); + return Err(cross_network_refusal( + info.chain.as_deref(), + expected, + "refusing to broadcast; the transaction was not sent and the draft is unchanged", + )); } Ok(()) } +/// The refusal a cross-network node earns, with what the caller was about to do. +/// +/// Two guards raise it — the sync path and the broadcast path — and each used +/// to spell it out, so the sentence the user reads depended on which one fired +/// first. `consequence` is the only part that legitimately differs: what did +/// not happen, and what state was left alone. +fn cross_network_refusal(reported: Option<&str>, expected: &str, consequence: &str) -> AppError { + let reported = reported.unwrap_or("unknown"); + AppError::InvalidInput(format!( + "node is on network '{reported}' but this wallet is '{expected}' — {consequence}" + )) +} + /// Broadcast a signed draft via node RPC. #[tauri::command] #[cfg_attr(coverage_nightly, coverage(off))] @@ -2009,9 +2024,11 @@ pub async fn get_write_capability( .ok() .and_then(|p| db::queries::get_profile_addresses(&conn, &p.id).ok()) .and_then(|addrs| addrs.into_iter().next()); - let expected_network = db::queries::get_active_profile_network(&conn) - .ok() - .flatten(); + // Returned, not swallowed. `Ok(None)` already means "no profile to + // compare against" and leaves the chain check permissive by design; a + // DB failure reaching the same `None` would report a wallet as ready to + // send through a node whose chain was never compared. + let expected_network = db::queries::get_active_profile_network(&conn)?; ( source, allow_remote, @@ -2090,8 +2107,8 @@ pub(crate) async fn apply_node_write_probe_with_client( info.chain.as_deref(), ) == Some(false); // "Synced" = applied blocks caught up to the best known header; see - // `chain_synced` for why verificationprogress wins. No metadata at - // all counts as synced (regtest). + // `chain_synced` for why the tip decides and verificationprogress + // only corroborates. No metadata at all counts as synced (regtest). let synced = info.is_synced(/* assume_when_unknown */ true); if chain_mismatch { let reported = info.chain.as_deref().unwrap_or("unknown"); @@ -2619,4 +2636,23 @@ mod pure_helper_tests { fn local_txid_from_summary_none_when_json_is_invalid() { assert_eq!(local_txid_from_summary("not { valid json"), None); } + + #[test] + fn output_names_from_pairs_keeps_each_index_with_its_name() { + // The Ledger parse-mode builder is told which output carries which + // name; pairing them by position is the whole job, and getting it + // wrong labels a covenant on the device with another output's name. + let out = + output_names_from_pairs(&[(0, "example".to_string()), (2, "another".to_string())]); + assert_eq!(out.len(), 2); + assert_eq!(out[0].output_index, 0); + assert_eq!(out[0].name, "example"); + assert_eq!(out[1].output_index, 2); + assert_eq!(out[1].name, "another"); + } + + #[test] + fn output_names_from_pairs_maps_an_empty_slice_to_an_empty_vec() { + assert!(output_names_from_pairs(&[]).is_empty()); + } } diff --git a/src-tauri/src/daemon/watched_names.rs b/src-tauri/src/daemon/watched_names.rs index 05ac8b22..b2e6703c 100644 --- a/src-tauri/src/daemon/watched_names.rs +++ b/src-tauri/src/daemon/watched_names.rs @@ -477,7 +477,9 @@ async fn try_run_watched_scan(db_path: &str) -> Result<(), AppError> { let watched = list_watched_names(&conn)?; let prev_states = load_prev_snapshots(&conn)?; let poll_meta = load_poll_meta(&conn)?; - let expected_network = queries::get_active_profile_network(&conn).ok().flatten(); + // The neighbours above already propagate; a network this pass cannot + // read must not make the node authoritative without the chain check. + let expected_network = queries::get_active_profile_network(&conn)?; let active_profile_id = queries::get_active_profile_id(&conn) .ok() .filter(|s| !s.is_empty()); @@ -519,15 +521,18 @@ async fn try_run_watched_scan(db_path: &str) -> Result<(), AppError> { // `node_ready_from_profile` resolves the effective config identically. // Only the no-active-profile fallback uses global settings. let node_ready = if let Some(profile_id) = active_profile_id.as_deref() { - crate::commands::read::node_ready_from_profile( + crate::commands::node_readiness::node_ready_from_profile( db_path, profile_id, expected_network.as_deref(), ) .await } else { - crate::commands::read::node_ready_from_settings(&settings, expected_network.as_deref()) - .await + crate::commands::node_readiness::node_ready_from_settings( + &settings, + expected_network.as_deref(), + ) + .await }; // 3. Adaptive skip + fetch. Bounded concurrency (4) to avoid hammering hsd. @@ -582,7 +587,6 @@ async fn try_run_watched_scan(db_path: &str) -> Result<(), AppError> { /// every 60s, so simple sequential polling is preferable to pulling in a /// streaming-concurrency dependency. Names that error out or return /// null/unparsable data are silently dropped; they'll be retried next cycle. -#[cfg_attr(coverage_nightly, coverage(off))] async fn fetch_all( node: &dyn crate::noncustodial::node_rpc::NodeRpc, names: &[String], @@ -1860,14 +1864,7 @@ mod tests { // --- Per-profile node config resolution for the watched-names daemon ----- - fn set_profile_override(conn: &rusqlite::Connection, profile_id: &str, key: &str, value: &str) { - conn.execute( - "INSERT INTO profile_settings (profile_id, key, value) VALUES (?1, ?2, ?3) - ON CONFLICT(profile_id, key) DO UPDATE SET value = excluded.value", - rusqlite::params![profile_id, key, value], - ) - .unwrap(); - } + use crate::tests::command_helpers::set_profile_override; #[test] fn resolve_watched_client_uses_active_profile_override() { diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 43c9e608..2718a40e 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -50,6 +50,10 @@ const MIGRATIONS: &[(&str, &str)] = &[ ("029", include_str!("../sql/029_bid_auction_scope.sql")), ("030", include_str!("../sql/030_bid_commitment_auction.sql")), ("031", include_str!("../sql/031_rescan_reveal_pairing.sql")), + ( + "032", + include_str!("../sql/032_clear_seeded_mainnet_explorer.sql"), + ), ]; pub fn run(conn: &Connection) -> Result<(), rusqlite::Error> { @@ -91,7 +95,7 @@ mod tests { let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 31, "expected 31 migrations, got {count}"); + assert_eq!(count, 32, "expected 32 migrations, got {count}"); } #[test] @@ -102,7 +106,7 @@ mod tests { let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 31); + assert_eq!(count, 32); } #[test] diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index fdc09320..604ce78a 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1559,8 +1559,8 @@ pub fn has_pending_draft_for_name( Ok(false) } -/// The action of a transaction this wallet has BROADCAST for `name` that the -/// chain has not confirmed yet — `Some("open")`, `Some("reveal")`, and so on. +/// Every action this wallet has BROADCAST for `name` that the chain has not +/// mined yet — `"open"`, `"reveal"` and so on — newest first. /// /// This is the gap the UI has to narrate. Between broadcast and the next block /// the chain still reports the name's previous state, so every phase-derived @@ -1569,15 +1569,13 @@ pub fn has_pending_draft_for_name( /// is indefinite. /// /// Only `broadcast_pending`/`broadcasted` count: a `draft` or `signed` row has -/// not left the device, and `confirmed`/`dropped`/`failed` are settled. When -/// several qualify — a name can legitimately have more than one in flight — the -/// most recent wins, which is the one the user just sent. -/// Every action this wallet has broadcast for `name` and the chain has not -/// mined, newest first. More than one can be in flight at once — a register -/// and a redeem on the same name spend different coins and are independent — -/// so a single answer has to pick, and `created_at` has second resolution: -/// two drafts made in the same second order arbitrarily. Callers that ask -/// "is a transaction of this kind in flight?" must look at all of them. +/// not left the device, and `confirmed`/`dropped`/`failed` are settled. +/// +/// More than one can be in flight at once — a register and a redeem on the +/// same name spend different coins and are independent — so a caller asking +/// "is a transaction of this kind in flight?" must look at all of them rather +/// than at the first. `created_at` has second resolution, so two drafts made +/// in the same second order arbitrarily between themselves. pub fn pending_broadcast_actions_for_name( conn: &rusqlite::Connection, profile_id: &str, @@ -1601,26 +1599,22 @@ pub fn pending_broadcast_actions_for_name( Ok(out) } +/// The most recent of [`pending_broadcast_actions_for_name`], or `None` when +/// nothing this wallet sent for `name` is still waiting for a block. +/// +/// "Most recent" is the one the user just pressed, which is what a single +/// "waiting for a block" label should name. Ordering is by `created_at`, which +/// has second resolution, so two drafts made in the same second pick between +/// themselves arbitrarily — a caller that must not miss one of several in +/// flight wants the plural form instead. pub fn pending_broadcast_action_for_name( conn: &rusqlite::Connection, profile_id: &str, name: &str, ) -> Result, AppError> { - let sql = format!( - "SELECT {DRAFT_COLS} FROM wallet_tx_drafts - WHERE wallet_profile_id = ?1 - AND status IN ('broadcast_pending','broadcasted') - ORDER BY created_at DESC" - ); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(params![profile_id], row_to_draft)?; - for r in rows { - let row = r?; - if draft_summary_covers_name(&row.summary_json, name) { - return Ok(Some(row.action)); - } - } - Ok(None) + Ok(pending_broadcast_actions_for_name(conn, profile_id, name)? + .into_iter() + .next()) } /// True when a draft's `summary_json` names `name` — either as its single @@ -2490,6 +2484,29 @@ pub struct BidCommitmentRow { pub name_start_height: Option, } +impl BidCommitmentRow { + /// Whether this commitment belongs to the auction that opened at + /// `auction_start`. + /// + /// A name can be auctioned many times: one nobody reveals in lapses and the + /// name becomes available again, so a commitment from a dead auction must + /// not count as a bid on the live one. + /// + /// Two unknowns are deliberately permissive. A commitment recovered from + /// the chain rather than built here has no recorded auction (migration + /// 030), and a caller that could not resolve the auction's start passes + /// `None`; in both cases counting one that may be dead is a wrong number on + /// screen, while hiding a live one is a bid the user is never told to + /// reveal. Only a recorded mismatch excludes. + pub fn belongs_to_auction(&self, auction_start: Option) -> bool { + match (auction_start, self.name_start_height) { + (Some(start), Some(placed)) => placed == start, + (Some(_), None) => true, + (None, _) => true, + } + } +} + /// Insert a bid commitment row. Errors (rather than silently no-op'ing) when a /// row with the same `(wallet_profile_id, name, blind_hex)` already exists. /// @@ -2749,6 +2766,42 @@ pub fn set_bid_reveal_txid( Ok(()) } +/// Whether a wallet profile row exists. +/// +/// Node-config resolution (ADR-001) treats a missing profile as a hard error +/// rather than a fallback to global settings, so it has to ask this before it +/// merges anything. +pub fn wallet_profile_exists( + conn: &rusqlite::Connection, + profile_id: &str, +) -> Result { + Ok(conn.query_row( + "SELECT COUNT(*) > 0 FROM wallet_profiles WHERE id = ?1", + [profile_id], + |row| row.get(0), + )?) +} + +/// All `profile_settings` rows for one profile, as a key/value map. +/// +/// These are the per-profile overrides of ADR-001. An absent key means "no +/// choice made here", which resolution reads as "fall back to global". +pub fn get_profile_settings( + conn: &rusqlite::Connection, + profile_id: &str, +) -> Result, AppError> { + let mut stmt = conn.prepare("SELECT key, value FROM profile_settings WHERE profile_id = ?1")?; + let rows = stmt.query_map([profile_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + let mut map = std::collections::HashMap::new(); + for row in rows { + let (k, v) = row?; + map.insert(k, v); + } + Ok(map) +} + #[cfg(test)] mod noncustodial_query_tests { use super::*; @@ -4133,7 +4186,7 @@ mod noncustodial_query_tests { // --- Coverage: reachable branches flagged uncovered in Phase 4 ---------- - /// Item 1 (queries.rs:1185-1189): a coin already reserved by a *different* + /// `insert_tx_draft_reserving_coins`: a coin already reserved by a *different* /// live draft cannot be stolen — the conditional UPDATE claims 0 rows, so /// the whole transaction rolls back with `InvalidInput` and draft B never /// persists. diff --git a/src-tauri/src/noncustodial/cookie_vault.rs b/src-tauri/src/noncustodial/cookie_vault.rs index bbc5877f..accdc7bb 100644 --- a/src-tauri/src/noncustodial/cookie_vault.rs +++ b/src-tauri/src/noncustodial/cookie_vault.rs @@ -324,10 +324,15 @@ pub fn decrypt_cookie(blob_hex: &str) -> Result, AppError> { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; - /// Serializes all tests that mutate the process-global `TEST_DEK` slot so - /// they don't race each other under cargo's parallel test runner. - static DEK_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); + // Tests that mutate the process-global `TEST_DEK` / test-backend slots + // carry `#[serial(cookie_vault)]`. A module-local mutex used to do this + // job, which excluded these tests from each other but not from + // `tests::namebase_cmd_tests`, whose every test already serializes on the + // `cookie_vault` key and calls `set_test_dek` through the same slots. Two + // locks around one piece of global state is no lock at all; one key covers + // both files. // A fixed DEK for pure-crypto tests. These tests exercise the crypto // envelope directly (bypassing the OS keyring), so they never touch the @@ -492,6 +497,7 @@ mod tests { /// the risk; the real guarantee is the `#[cfg(any(test, debug_assertions))]` /// on the item itself, verified to compile-out by the release profile. #[test] + #[serial(cookie_vault)] fn test_dek_slot_present_only_under_debug_or_test() { // Under `cargo test`, cfg(test) is set, so `set_test_dek` is compiled // in and callable — exercised here to keep the bypass path covered. @@ -499,7 +505,6 @@ mod tests { // `#[cfg(any(test, debug_assertions))]` attribute on the item, not by // this test: a `--release` build has `debug_assertions` off, so the // function and its backing slot are not compiled at all. - let _held = DEK_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); set_test_dek(Some(vec![0u8; DEK_LEN])); set_test_dek(None); } @@ -554,12 +559,12 @@ mod tests { /// branch in `get_or_create_dek` (line 154) and the DEK-zeroize wrappers /// without touching the OS keyring. /// - /// Serialized (not `#[serial]`, which isn't a dep here) via a module mutex - /// so it doesn't race other tests that toggle the shared TEST_DEK slot. + /// Serialized on the `cookie_vault` key so it does not race the other + /// tests — here or in `namebase_cmd_tests` — that toggle the shared + /// TEST_DEK slot. #[test] + #[serial(cookie_vault)] fn public_cookie_roundtrip_with_test_dek() { - let _held = DEK_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); - set_test_dek(Some(test_dek())); let plaintext = b"session=xyz; secure; httponly"; let blob_hex = encrypt_cookie(plaintext).expect("encrypt_cookie"); @@ -572,9 +577,8 @@ mod tests { /// `encrypt_cookie` propagates the empty-plaintext rejection through the /// public API (with a test DEK installed). #[test] + #[serial(cookie_vault)] fn public_encrypt_cookie_rejects_empty() { - let _held = DEK_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); - set_test_dek(Some(test_dek())); let err = encrypt_cookie(b"").unwrap_err(); assert!(matches!(err, AppError::InvalidInput(_)), "got {err:?}"); @@ -585,9 +589,8 @@ mod tests { /// (the `return Ok(dek)` when a fixed DEK is installed) without going /// through the public encrypt/decrypt wrappers. #[test] + #[serial(cookie_vault)] fn get_or_create_dek_returns_installed_test_dek() { - let _held = DEK_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); - let fixed = test_dek(); set_test_dek(Some(fixed.clone())); let got = get_or_create_dek().expect("test DEK should be returned"); @@ -712,15 +715,11 @@ mod tests { ); } - /// Serializes tests that mutate the process-global test-backend slot. - static BACKEND_TEST_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); - #[test] + #[serial(cookie_vault)] fn get_or_create_dek_uses_installed_test_backend_existing_entry() { // Must NOT race with test-DEK-slot tests either: get_or_create_dek - // consults TEST_DEK first. - let _dek_held = DEK_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); - let _held = BACKEND_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); + // consults TEST_DEK first — the shared serial key covers both slots. set_test_dek(None); let fixed = vec![9u8; DEK_LEN]; @@ -734,10 +733,8 @@ mod tests { } #[test] + #[serial(cookie_vault)] fn get_or_create_dek_uses_installed_test_backend_new_entry() { - let _dek_held = DEK_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); - let _held = BACKEND_TEST_GUARD.lock().unwrap_or_else(|p| p.into_inner()); - set_test_dek(None); set_test_keyring_backend(Some(Box::new(FakeKeyring::empty()))); diff --git a/src-tauri/src/noncustodial/covenants.rs b/src-tauri/src/noncustodial/covenants.rs index 95d94911..89cd5cf6 100644 --- a/src-tauri/src/noncustodial/covenants.rs +++ b/src-tauri/src/noncustodial/covenants.rs @@ -15,6 +15,29 @@ use crate::noncustodial::sync::{ }; use crate::noncustodial::tx::Covenant; +/// Whether an owner coin's covenant type means the name is REGISTERED — the +/// point at which holding the coin becomes the right to act on the name. +/// +/// Owning is not the same question. During REVEAL `getnameinfo` already names +/// the highest revealer as the owner, so a wallet merely leading its own +/// auction holds a REVEAL coin and nothing more — and hsd lets a REVEAL coin go +/// only to a REGISTER or a REDEEM (`rules.verifyCovenants`), so every ownership +/// action would be refused by the node. `None` is "we do not hold the coin", +/// which is not registered either. +/// +/// The comparison is `>= COV_REGISTER` rather than `== `: REGISTER, UPDATE, +/// RENEW, TRANSFER, FINALIZE and REVOKE all sit above it and all mean the name +/// has been registered at some point. +/// +/// One predicate because the capability gates, the task-state derivation and +/// `sign_name_message` all need the same answer, and spelling it out separately +/// is how they came to disagree about what "owned" means. +pub fn is_registered_owner_covenant(covenant_type: Option) -> bool { + covenant_type + .map(|t| t >= COV_REGISTER as i64) + .unwrap_or(false) +} + fn u32le(n: u32) -> Vec { n.to_le_bytes().to_vec() } diff --git a/src-tauri/src/noncustodial/node_config.rs b/src-tauri/src/noncustodial/node_config.rs index 59a7b225..cd5e69d0 100644 --- a/src-tauri/src/noncustodial/node_config.rs +++ b/src-tauri/src/noncustodial/node_config.rs @@ -9,17 +9,29 @@ //! //! A per-profile override is an explicit user choice: when present it is used //! verbatim and never silently rewritten (e.g. `realign_loopback_rpc_url` is -//! skipped for an overridden URL — see [`EffectiveNodeConfig::from_override`]). - -use std::collections::HashMap; +//! skipped for an overridden URL — see +//! [`EffectiveNodeConfig::url_from_override`]). use crate::error::AppError; use crate::noncustodial::rpc::{resolve_node_api_key, ChainSource}; /// The built-in default node RPC URL when neither an override nor a global -/// setting supplies one. Mainnet's default hsd node port; realign fixes the -/// port for other networks when this comes from the global fallback. -pub const DEFAULT_NODE_RPC_URL: &str = "http://127.0.0.1:12037"; +/// setting supplies one: mainnet's default hsd port, from +/// [`Network::default_rpc_url`] rather than spelled out again here. Realign +/// fixes the port for other networks when this came from the global fallback. +/// +/// Resolution has no profile network to consult — it resolves the endpoint +/// tuple and deliberately does not touch the profile's network — so mainnet is +/// the only defensible default, and realign is what corrects it. +pub fn default_node_rpc_url() -> String { + crate::noncustodial::network::Network::Main.default_rpc_url() +} + +/// The `profile_settings` keys that make up the ADR-001 node-config tuple. +/// A profile may hold other per-profile settings; those say nothing about +/// which node this profile talks to, so they must not make the config read as +/// overridden. +const NODE_CONFIG_KEYS: [&str; 3] = ["node_rpc_url", "node_rpc_api_key", "chain_source"]; /// The resolved node configuration for one profile. #[derive(Debug, Clone, PartialEq, Eq)] @@ -30,28 +42,18 @@ pub struct EffectiveNodeConfig { pub node_rpc_api_key: String, /// Resolved chain source. pub chain_source: ChainSource, - /// True when at least one node-config key came from the profile's - /// `profile_settings` override (as opposed to global/default). Callers use - /// this to skip `realign_loopback_rpc_url`, which must only touch a URL that - /// came from the global fallback. + /// True when the profile's own `profile_settings` supplied any key of the + /// node-config tuple — whatever value it holds. "The user chose this here", + /// not "this differs from global": a user who deliberately pins a profile + /// to the value global happens to carry today has still chosen it, and + /// global can change under them afterwards. pub from_override: bool, -} - -/// Read all `profile_settings` rows for a profile into a key/value map. -pub fn get_profile_settings( - conn: &rusqlite::Connection, - profile_id: &str, -) -> Result, AppError> { - let mut stmt = conn.prepare("SELECT key, value FROM profile_settings WHERE profile_id = ?1")?; - let rows = stmt.query_map([profile_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - })?; - let mut map = HashMap::new(); - for row in rows { - let (k, v) = row?; - map.insert(k, v); - } - Ok(map) + /// True when the profile's own `profile_settings` supplied the resolved + /// `node_rpc_url`. This is the one realign must consult: it rewrites the + /// URL and nothing else, so an override of `chain_source` alone is no + /// reason to leave a stale global loopback port pointing at the wrong + /// network (ADR-001, "Interaction with N11"). + pub url_from_override: bool, } /// Resolve the effective node configuration for `profile_id`. @@ -65,40 +67,36 @@ pub fn effective_node_config_for_profile( profile_id: &str, ) -> Result { // A missing profile is a hard error, never a silent default (ADR-001). - let exists: bool = conn.query_row( - "SELECT COUNT(*) > 0 FROM wallet_profiles WHERE id = ?1", - [profile_id], - |row| row.get(0), - )?; - if !exists { + if !crate::db::queries::wallet_profile_exists(conn, profile_id)? { return Err(AppError::NotFound(format!("wallet profile {profile_id}"))); } let global = crate::db::queries::get_settings(conn)?; - let overrides = get_profile_settings(conn, profile_id)?; + let overrides = crate::db::queries::get_profile_settings(conn, profile_id)?; // Build a merged settings view (override -> global) so the existing // resolvers (`resolve_node_api_key`, `ChainSource::from_settings`) apply the // same precedence without re-implementing their rules. A non-empty override // wins; an empty override string is "no meaningful choice" and must not - // blank out a good global value. `from_override` is set when any override - // key actually changes the resolved value away from the global fallback. + // blank out a good global value. + let chosen_here = |key: &str| { + overrides + .get(key) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) + }; let mut merged = global.clone(); - let mut from_override = false; for (k, v) in &overrides { if v.trim().is_empty() { continue; } - if merged.get(k).map(String::as_str) != Some(v.as_str()) { - from_override = true; - } merged.insert(k.clone(), v.clone()); } let node_rpc_url = merged .get("node_rpc_url") .map(String::to_string) - .unwrap_or_else(|| DEFAULT_NODE_RPC_URL.to_string()); + .unwrap_or_else(default_node_rpc_url); let node_rpc_api_key = resolve_node_api_key(&merged); let chain_source = ChainSource::from_settings(&merged); @@ -106,6 +104,7 @@ pub fn effective_node_config_for_profile( node_rpc_url, node_rpc_api_key, chain_source, - from_override, + from_override: NODE_CONFIG_KEYS.iter().any(|k| chosen_here(k)), + url_from_override: chosen_here("node_rpc_url"), }) } diff --git a/src-tauri/src/noncustodial/rpc.rs b/src-tauri/src/noncustodial/rpc.rs index 92bcdb7a..5504b93b 100644 --- a/src-tauri/src/noncustodial/rpc.rs +++ b/src-tauri/src/noncustodial/rpc.rs @@ -396,8 +396,12 @@ impl NodeRpcClient { self.source } - /// Test-only accessor for the resolved node URL (after trailing-slash trim). - #[cfg(test)] + /// The node URL this client actually talks to (after trailing-slash trim). + /// + /// A message that names the node must read it from here rather than from + /// global settings: a profile may resolve to its own endpoint (ADR-001), + /// and naming the global URL then sends the user to fix a node the failing + /// request never touched. pub fn node_url(&self) -> &str { &self.node_url } @@ -865,6 +869,12 @@ impl BlockchainInfo { } } +/// Loose floor below which a `blocks == headers` match is distrusted as +/// "headers not yet at the real tip" (see `chain_synced`'s ~8%-verified case). +const HEADERS_MATCH_PROGRESS_FLOOR: f64 = 0.999; +/// Progress gate used when the node reports no header height to compare against. +const PROGRESS_ONLY_SYNCED_GATE: f64 = 0.9999; + /// The one "is this node synced?" rule, shared by the read/write gates, the /// node-status probe and the remote-node connection check. /// @@ -885,12 +895,6 @@ impl BlockchainInfo { /// answer is `assume_when_unknown`: callers gating spends on a configured node /// pass `true` so regtest keeps working, while a first-contact probe of an /// unknown remote node passes `false`. -/// -/// Loose floor below which a `blocks == headers` match is distrusted as -/// "headers not yet at the real tip" (see the ~8%-verified case above). -const HEADERS_MATCH_PROGRESS_FLOOR: f64 = 0.999; -/// Progress gate used when the node reports no header height to compare against. -const PROGRESS_ONLY_SYNCED_GATE: f64 = 0.9999; pub fn chain_synced( blocks: i64, headers: Option, diff --git a/src-tauri/src/providers/hnsfans.rs b/src-tauri/src/providers/hnsfans.rs index 52f158f1..7131ba96 100644 --- a/src-tauri/src/providers/hnsfans.rs +++ b/src-tauri/src/providers/hnsfans.rs @@ -1220,9 +1220,9 @@ mod tests { #[tokio::test] async fn get_name_info_optional_returns_error_on_http_failure() { // A 4xx status (other than 404) surfaces the "HNSFans name lookup failed" - // AppError::Other branch. Covers hnsfans.rs L282-286: 5xx is handled by - // get_with_fallback (returns before ever reaching this branch); 4xx - // codes flow through as an Ok(resp) here. + // AppError::Other branch in `get_name_info_optional`. 5xx never reaches + // it: `get_with_fallback` returns before this status check; 4xx codes + // flow through as an Ok(resp) here. let mut server = mockito::Server::new_async().await; let _m = server .mock("GET", "/api/names/testname") @@ -1247,7 +1247,7 @@ mod tests { // --- Coverage: reachable branches flagged uncovered in Phase 4 ---------- - /// Item 9 (hnsfans.rs:183): `get_balance` skips empty/whitespace addresses + /// `get_balance` skips empty/whitespace addresses /// via `continue`, so passing only empty addresses results in `attempted=0` /// and returns `Ok` with zero balance (not an error). #[tokio::test] @@ -1261,7 +1261,7 @@ mod tests { assert_eq!(balance.unconfirmed, 0); } - /// Item 10 (hnsfans.rs:605): `extract_amount` with a float value calls + /// `extract_amount` with a float value calls /// `as_f64()` and rounds. This is already covered by the existing /// `extract_amount_rounds_floats_and_defaults_to_zero` test, but we verify /// it here explicitly. @@ -1271,7 +1271,7 @@ mod tests { assert_eq!(extract_amount(&body, &["confirmed"]), 13); } - /// Item 11 (hnsfans.rs:697): `normalize_name` handles `transfer` field + /// `normalize_name` handles `transfer` field /// that is neither a Number nor Null (e.g., a string or boolean). #[test] fn normalize_name_handles_transfer_non_number_non_null() { diff --git a/src-tauri/src/providers/ledger/hid_transport.rs b/src-tauri/src/providers/ledger/hid_transport.rs index 49f29afb..097a76ea 100644 --- a/src-tauri/src/providers/ledger/hid_transport.rs +++ b/src-tauri/src/providers/ledger/hid_transport.rs @@ -173,7 +173,6 @@ impl Transport { } /// Human-readable message for a non-success status word. -#[cfg_attr(coverage_nightly, coverage(off))] pub fn status_word_message(sw: u16) -> String { let hint = match sw { 0x6985 => " (user rejected on device)", diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 4ed5e14e..30620ca6 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -23,6 +23,19 @@ pub use signer::{ WriteCapability, }; +/// What to tell the user when [`explorer_client_from_settings`] returns `None` +/// and the caller needed it. +/// +/// One sentence, in one place, because it was written four different ways +/// across the read and sync paths — and it has to cover both reasons the +/// factory refuses: the profile's network has no explorer configured, or the +/// network could not be read at all. Either way the actionable part is the +/// same, and naming the setting beats degrading to empty or to mainnet data. +pub const EXPLORER_UNAVAILABLE: &str = + "No explorer is available for this wallet's network, and the local node is \ + not synced. Set 'explorer_api_url' in Settings, or wait for the node to \ + finish syncing."; + /// The ONE place settings turn into an explorer client (Task 11 / S1, G2). /// /// Before this, `HnsFansClient::new(...)` was constructed at three separate @@ -36,11 +49,22 @@ pub use signer::{ /// data. On testnet/regtest/simnet there is no known public explorer, so /// pointing a non-mainnet wallet at `e.hnsfans.com` produced false "no data" /// results. Resolution order: -/// 1. explicit `explorer_api_url` from settings (any network), else +/// 1. explicit `explorer_api_url` from settings, unless it is the known +/// mainnet explorer and the profile is not on mainnet, else /// 2. [`Network::default_explorer_base_url`] (mainnet only), else /// 3. `None` — the explorer fallback is *disabled* and callers must degrade /// to cache or a candid "explorer unavailable" error instead of mainnet. /// +/// Step 1 refuses the mainnet explorer off mainnet because the stored setting +/// is not always something the user chose: migration 009 seeded it with the +/// mainnet URL, and every installation that ran that version carries the value +/// still (032 clears exactly that seeded value, but a database can reach this +/// code before migrations of a newer build have run). An explicit URL outranks +/// the network default, so without this check the seeded mainnet URL silently +/// won on a testnet or regtest profile — the cross-network read the guard is +/// for. Any other URL is the user's own and is honoured on every network: the +/// wallet cannot know which chain a private explorer serves. +/// /// If `explorer_fallback_url` is set in settings, the client will /// automatically fail over to it when the primary explorer is unreachable. pub fn explorer_client_from_settings( @@ -50,7 +74,11 @@ pub fn explorer_client_from_settings( let explicit = settings .get("explorer_api_url") .map(|s| s.trim()) - .filter(|s| !s.is_empty()); + .filter(|s| !s.is_empty()) + .filter(|u| { + network == crate::noncustodial::network::Network::Main + || u.trim_end_matches('/') != hnsfans::DEFAULT_EXPLORER_URL + }); let url = match explicit { Some(u) => u, None => network.default_explorer_base_url()?, diff --git a/src-tauri/src/sql/010_drop_legacy_settings.sql b/src-tauri/src/sql/010_drop_legacy_settings.sql index da19ef8e..0ab3fa0c 100644 --- a/src-tauri/src/sql/010_drop_legacy_settings.sql +++ b/src-tauri/src/sql/010_drop_legacy_settings.sql @@ -5,6 +5,16 @@ -- `chain_source` and `allow_remote_broadcast` were once listed here, then -- reintroduced as live settings (remote-node support); they are no longer -- deleted. +-- +-- `hsd_prefix` IS still deleted here and was also reintroduced, by 011, which +-- re-seeds it empty. On a database upgrading across this point that costs the +-- user their configured hsd data directory: 011's `INSERT OR IGNORE` puts back +-- an empty value, not theirs, so the app falls back to `~/.hsd` and the chain +-- appears to have vanished. Left as it is on purpose — a migration is a record +-- of a transformation that already ran, and editing a shipped one would change +-- history for databases that have not reached it while doing nothing for those +-- that have. Anyone re-seeding a setting a later migration restores should +-- carry the old value across instead of dropping it. DELETE FROM settings WHERE key IN ( 'hsd_wallet_api_url', 'hsd_node_api_url', diff --git a/src-tauri/src/sql/032_clear_seeded_mainnet_explorer.sql b/src-tauri/src/sql/032_clear_seeded_mainnet_explorer.sql new file mode 100644 index 00000000..94421081 --- /dev/null +++ b/src-tauri/src/sql/032_clear_seeded_mainnet_explorer.sql @@ -0,0 +1,19 @@ +-- Clear the mainnet explorer URL that migration 009 used to seed. +-- +-- 009 originally seeded `explorer_api_url` with the mainnet explorer. It was +-- later changed to seed an empty string, so the runtime resolves the explorer +-- per the active profile's network (`Network::default_explorer_base_url`, G2) +-- and a testnet/regtest profile has no mainnet URL to inherit. That edit only +-- helps a database created after it: on an installation where 009 had already +-- run, the mainnet URL stayed in `settings`, and an explicit `explorer_api_url` +-- outranks the network default — so a testnet or regtest profile kept reading +-- from the mainnet explorer, which is the silent cross-network read the guard +-- exists to prevent. +-- +-- Only the exact value 009 seeded is removed. A URL the user typed themselves +-- is theirs, is not necessarily mainnet, and is left alone; the network guard +-- in `providers::explorer_client_from_settings` refuses it when it disagrees +-- with the profile's network. +DELETE FROM settings + WHERE key = 'explorer_api_url' + AND value = 'https://e.hnsfans.com'; diff --git a/src-tauri/src/tests/active_profile_tests.rs b/src-tauri/src/tests/active_profile_tests.rs index 2e750fa2..681c36d2 100644 --- a/src-tauri/src/tests/active_profile_tests.rs +++ b/src-tauri/src/tests/active_profile_tests.rs @@ -3,8 +3,10 @@ use crate::commands::active_profile::{ active_profile_network_from_conn, active_profile_network_opt_from_conn, + profile_network_from_conn, }; use crate::db::queries::{insert_wallet_profile, set_active_profile}; +use crate::error::AppError; use crate::noncustodial::network::Network; use rusqlite::Connection; @@ -90,3 +92,67 @@ fn the_optional_form_reads_the_active_profile_network() { Some(Network::Regtest) ); } + +// --- profile_network_opt_from_conn: a named profile, unknown means unknown --- + +#[test] +fn a_named_profiles_network_is_read_without_the_active_profile() { + // The sync steps work on a profile id they were handed, which need not be + // the active one. + let conn = db(); + seed_profile(&conn, "p-regtest", "regtest"); + seed_profile(&conn, "p-main", "mainnet"); + set_active_profile(&conn, "p-main").unwrap(); + assert_eq!( + crate::commands::active_profile::profile_network_opt_from_conn(&conn, "p-regtest"), + Some(Network::Regtest) + ); +} + +#[test] +fn a_missing_profile_reads_as_unknown_not_mainnet() { + let conn = db(); + assert_eq!( + crate::commands::active_profile::profile_network_opt_from_conn(&conn, "nobody"), + None + ); +} + +#[test] +fn an_unparseable_network_string_reads_as_unknown_not_mainnet() { + let conn = db(); + // Same smuggling as the fallback test above: the schema's CHECK keeps + // unknown networks out, so this branch is defensive (an older DB, a future + // network name) rather than a state the app can produce. + conn.pragma_update(None, "ignore_check_constraints", true) + .unwrap(); + seed_profile(&conn, "p1", "weirdnet"); + conn.pragma_update(None, "ignore_check_constraints", false) + .unwrap(); + assert_eq!( + crate::commands::active_profile::profile_network_opt_from_conn(&conn, "p1"), + None, + "unknown must not resolve to mainnet for a step that acts on the answer" + ); +} + +#[test] +fn the_named_profile_form_reads_that_profile_network() { + let conn = db(); + seed_profile(&conn, "p1", "regtest"); + seed_profile(&conn, "p2", "testnet"); + set_active_profile(&conn, "p1").unwrap(); + assert_eq!( + profile_network_from_conn(&conn, "p2").unwrap(), + Network::Testnet + ); +} + +#[test] +fn the_named_profile_form_errors_rather_than_guessing_mainnet() { + let conn = db(); + assert!(matches!( + profile_network_from_conn(&conn, "missing"), + Err(AppError::NotFound(_)) + )); +} diff --git a/src-tauri/src/tests/auction_capabilities_tests.rs b/src-tauri/src/tests/auction_capabilities_tests.rs index 47a31e6e..9676efdc 100644 --- a/src-tauri/src/tests/auction_capabilities_tests.rs +++ b/src-tauri/src/tests/auction_capabilities_tests.rs @@ -5,7 +5,8 @@ //! the pure derivation functions directly. use crate::commands::names::{ - derive_auction_task_state, next_action_for_task, AuctionTaskState, EXPIRING_SOON_THRESHOLD_DAYS, + derive_auction_task_state, next_action_for_task, AuctionTaskState, NameActionContext, + EXPIRING_SOON_THRESHOLD_DAYS, }; use crate::noncustodial::network::Network; @@ -22,17 +23,15 @@ fn state_no_owner( has_reveal: bool, ) -> AuctionTaskState { derive_auction_task_state( + &NameActionContext { + has_bid_commitment: has_bid, + has_bid_coin, + has_reveal_coin: has_reveal, + has_owner_coin: owns_name, + ..Default::default() + }, phase, owns_name, - has_bid, - has_bid_coin, - has_reveal, - owns_name, - None, - None, - false, - false, - None, None, Network::Main, ) @@ -47,17 +46,16 @@ fn state_registered( has_reveal: bool, ) -> AuctionTaskState { derive_auction_task_state( + &NameActionContext { + has_bid_commitment: has_bid, + has_bid_coin, + has_reveal_coin: has_reveal, + has_owner_coin: owns_name, + owner_covenant_type: Some(6), + ..Default::default() + }, phase, owns_name, - has_bid, - has_bid_coin, - has_reveal, - owns_name, - Some(6), - None, - false, - false, - None, None, Network::Main, ) @@ -72,17 +70,16 @@ fn state_unregistered( has_reveal: bool, ) -> AuctionTaskState { derive_auction_task_state( + &NameActionContext { + has_bid_commitment: has_bid, + has_bid_coin, + has_reveal_coin: has_reveal, + has_owner_coin: owns_name, + owner_covenant_type: Some(4), + ..Default::default() + }, phase, owns_name, - has_bid, - has_bid_coin, - has_reveal, - owns_name, - Some(4), - None, - false, - false, - None, None, Network::Main, ) @@ -91,18 +88,14 @@ fn state_unregistered( // Helper: registered owner coin + a known days-until-expire value. fn state_registered_days(phase: &str, days: Option) -> AuctionTaskState { derive_auction_task_state( + &NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(6), + ..Default::default() + }, phase, true, - false, - false, - false, - true, - Some(6), days, - false, - false, - None, - None, Network::Main, ) } @@ -139,17 +132,12 @@ fn available_with_pending_open_yields_waiting_for_bidding() { // WaitingForBidding variant instead of AvailableToOpen, before the phase // itself has advanced to OPENING. let state = derive_auction_task_state( + &NameActionContext { + has_pending_open: true, + ..Default::default() + }, "AVAILABLE", false, - false, - false, - false, - false, - None, - None, - true, - false, - None, None, Network::Main, ); @@ -159,17 +147,12 @@ fn available_with_pending_open_yields_waiting_for_bidding() { #[test] fn empty_phase_with_pending_open_yields_waiting_for_bidding() { let state = derive_auction_task_state( + &NameActionContext { + has_pending_open: true, + ..Default::default() + }, "", false, - false, - false, - false, - false, - None, - None, - true, - false, - None, None, Network::Main, ); @@ -181,17 +164,9 @@ fn available_without_pending_open_still_yields_available_to_open() { // Regression: has_pending_open=false must not change the pre-existing // AVAILABLE behavior. let state = derive_auction_task_state( + &NameActionContext::default(), "AVAILABLE", false, - false, - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -253,18 +228,16 @@ fn reveal_state( reveal_draft_status: Option<&str>, ) -> AuctionTaskState { derive_auction_task_state( + &NameActionContext { + has_bid_commitment: true, + has_bid_coin, + reveal_txid: reveal_txid.map(str::to_string), + reveal_draft_status: reveal_draft_status.map(str::to_string), + ..Default::default() + }, "REVEAL", false, - true, // has_bid_commitment - has_bid_coin, - false, // has_reveal_coin - false, - None, None, - false, - false, - reveal_txid, - reveal_draft_status, Network::Main, ) } @@ -377,17 +350,14 @@ fn a_recorded_transfer_yields_transfer_pending_finalize() { // a string the node never sends, and the real case fell through to // "no urgent action" on a name waiting to be finalized. let state = derive_auction_task_state( + &NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(crate::noncustodial::sync::COV_TRANSFER as i64), + transfer_has_items: Some(true), + ..Default::default() + }, "CLOSED", true, - false, - false, - false, - true, - Some(crate::noncustodial::sync::COV_TRANSFER as i64), - None, - false, - true, - None, None, Network::Main, ); @@ -454,18 +424,10 @@ fn explorer_owned_without_owner_coin_within_threshold_yields_expiring_soon() { // must still fire — renewals are exactly the case where staying silent // loses the name. let state = derive_auction_task_state( + &NameActionContext::default(), "CLOSED", true, - false, - false, - false, - false, - None, Some(5.0), - false, - false, - None, - None, Network::Main, ); assert_eq!(state, AuctionTaskState::ExpiringSoon); @@ -475,18 +437,14 @@ fn explorer_owned_without_owner_coin_within_threshold_yields_expiring_soon() { fn won_unregistered_within_threshold_still_needs_register_first() { // Registration takes precedence: an unregistered win can't be renewed. let state = derive_auction_task_state( + &NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(4), + ..Default::default() + }, "CLOSED", true, - false, - false, - false, - true, - Some(4), Some(5.0), - false, - false, - None, - None, Network::Main, ); assert_eq!(state, AuctionTaskState::WonNeedsRegister); @@ -496,18 +454,10 @@ fn won_unregistered_within_threshold_still_needs_register_first() { fn unowned_closed_within_threshold_is_not_expiring_soon() { // Not our name — no renewal alarm. let state = derive_auction_task_state( + &NameActionContext::default(), "CLOSED", false, - false, - false, - false, - false, - None, Some(5.0), - false, - false, - None, - None, Network::Main, ); assert_eq!(state, AuctionTaskState::OwnedNoUrgentAction); diff --git a/src-tauri/src/tests/build_batch_renew_draft_tests.rs b/src-tauri/src/tests/build_batch_renew_draft_tests.rs index 053cc5c1..5fd544ba 100644 --- a/src-tauri/src/tests/build_batch_renew_draft_tests.rs +++ b/src-tauri/src/tests/build_batch_renew_draft_tests.rs @@ -179,11 +179,9 @@ fn setup( #[test] fn batch_renew_happy_path_persists_draft() { let (conn, ctx, per_name) = setup(&["alpha", "bravo"]); - let names: Vec = vec!["alpha".into(), "bravo".into()]; let rblock = [0x55u8; 32]; - let summary = - build_batch_renew_draft_inner(&conn, &ctx, &names, per_name, &rblock, 10).unwrap(); + let summary = build_batch_renew_draft_inner(&conn, &ctx, per_name, &rblock, 10).unwrap(); assert_eq!(summary.action, "batch-renew"); let drafts: i64 = conn @@ -208,8 +206,7 @@ fn batch_renew_empty_persists_zero_input_draft() { // The async wrapper rejects empty `names` up front; if the inner is // called with an empty batch, `build_batch_plan` refuses (no outputs). let (conn, ctx, _per_name) = setup(&[]); - let err = - build_batch_renew_draft_inner(&conn, &ctx, &[], Vec::new(), &[0u8; 32], 10).unwrap_err(); + let err = build_batch_renew_draft_inner(&conn, &ctx, Vec::new(), &[0u8; 32], 10).unwrap_err(); match err { AppError::InvalidInput(msg) => assert!(msg.contains("at least one output"), "got {msg}"), other => panic!("expected InvalidInput, got {other:?}"), @@ -219,9 +216,7 @@ fn batch_renew_empty_persists_zero_input_draft() { #[test] fn batch_renew_single_name() { let (conn, ctx, per_name) = setup(&["solo"]); - let names: Vec = vec!["solo".into()]; - let summary = - build_batch_renew_draft_inner(&conn, &ctx, &names, per_name, &[0x11u8; 32], 20).unwrap(); + let summary = build_batch_renew_draft_inner(&conn, &ctx, per_name, &[0x11u8; 32], 20).unwrap(); assert_eq!(summary.action, "batch-renew"); let name_list = summary.summary.get("nameList"); assert!(name_list.is_some(), "batch draft records the name list"); diff --git a/src-tauri/src/tests/build_batch_transfer_draft_tests.rs b/src-tauri/src/tests/build_batch_transfer_draft_tests.rs index 56948e42..3564ac69 100644 --- a/src-tauri/src/tests/build_batch_transfer_draft_tests.rs +++ b/src-tauri/src/tests/build_batch_transfer_draft_tests.rs @@ -198,11 +198,9 @@ fn setup(names_in: &[&str]) -> Fixture { #[test] fn batch_transfer_happy_path_persists_draft() { let f = setup(&["alpha", "bravo"]); - let names: Vec = vec!["alpha".into(), "bravo".into()]; let summary = build_batch_transfer_draft_inner( &f.conn, &f.ctx, - &names, f.per_name, &f.recipient, f.version, @@ -249,7 +247,6 @@ fn batch_transfer_uses_owner_address_for_output() { // the recipient lives only in the covenant items. Guard both by parsing // the persisted plan. let f = setup(&["alpha", "bravo"]); - let names: Vec = vec!["alpha".into(), "bravo".into()]; let owner_addr = f.owner_addr.clone(); let program_hex = hex::encode(&f.program); let version = f.version; @@ -257,7 +254,6 @@ fn batch_transfer_uses_owner_address_for_output() { build_batch_transfer_draft_inner( &f.conn, &f.ctx, - &names, f.per_name, &f.recipient, f.version, @@ -311,11 +307,9 @@ fn batch_transfer_uses_owner_address_for_output() { #[test] fn batch_transfer_single_name() { let f = setup(&["solo"]); - let names: Vec = vec!["solo".into()]; let summary = build_batch_transfer_draft_inner( &f.conn, &f.ctx, - &names, f.per_name, &f.recipient, f.version, @@ -334,7 +328,6 @@ fn batch_transfer_empty_errors() { let err = build_batch_transfer_draft_inner( &f.conn, &f.ctx, - &[], Vec::new(), &f.recipient, f.version, diff --git a/src-tauri/src/tests/chain_scan_tests.rs b/src-tauri/src/tests/chain_scan_tests.rs index 32c3d87e..2e9dbcac 100644 --- a/src-tauri/src/tests/chain_scan_tests.rs +++ b/src-tauri/src/tests/chain_scan_tests.rs @@ -6,6 +6,7 @@ //! implicitly exercised by the DB shape here — an integration test against a //! real node would go through `live_node_it` (see the "live_node_it" pattern). +use crate::tests::command_helpers::set_profile_override; use rusqlite::{params, Connection}; use crate::commands::chain_scan::{read_indexed_bids, scan_cursor_height}; @@ -1196,15 +1197,6 @@ async fn scan_block_assigns_correct_vout_index() { // --- Per-profile node config resolution for chain_scan ---------------------- -fn set_profile_override(conn: &rusqlite::Connection, profile_id: &str, key: &str, value: &str) { - conn.execute( - "INSERT INTO profile_settings (profile_id, key, value) VALUES (?1, ?2, ?3) - ON CONFLICT(profile_id, key) DO UPDATE SET value = excluded.value", - rusqlite::params![profile_id, key, value], - ) - .unwrap(); -} - #[test] fn resolve_scanner_client_uses_active_profile_override() { use crate::db::queries::insert_wallet_profile; diff --git a/src-tauri/src/tests/command_helpers.rs b/src-tauri/src/tests/command_helpers.rs index b0d75c5f..4293800c 100644 --- a/src-tauri/src/tests/command_helpers.rs +++ b/src-tauri/src/tests/command_helpers.rs @@ -27,3 +27,19 @@ pub fn create_test_state() -> crate::AppState { )), } } + +/// Set one per-profile node-config override (ADR-001), upserting. +/// +/// Four test files and the watched-name daemon's own test module each carried +/// their own copy of this. Four were byte-identical; the fifth used +/// `INSERT OR REPLACE`, which differs on an existing row — it rewrites the +/// whole row rather than the value, so a future column would silently be +/// reset. One copy, and the shapes cannot drift again. +pub fn set_profile_override(conn: &Connection, profile_id: &str, key: &str, value: &str) { + conn.execute( + "INSERT INTO profile_settings (profile_id, key, value) VALUES (?1, ?2, ?3) + ON CONFLICT(profile_id, key) DO UPDATE SET value = excluded.value", + rusqlite::params![profile_id, key, value], + ) + .unwrap(); +} diff --git a/src-tauri/src/tests/connection_tests.rs b/src-tauri/src/tests/connection_tests.rs index a0a8de54..99ab0fd9 100644 --- a/src-tauri/src/tests/connection_tests.rs +++ b/src-tauri/src/tests/connection_tests.rs @@ -39,11 +39,11 @@ fn test_migrations_run_idempotent() { // Running migrations again should be a no-op (idempotent). crate::db::migrations::run(&conn).unwrap(); - // Verify all 31 migrations are recorded. + // Verify all 32 migrations are recorded. let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 31); + assert_eq!(count, 32); } #[test] diff --git a/src-tauri/src/tests/contract_shape_tests.rs b/src-tauri/src/tests/contract_shape_tests.rs index d6f3dc23..5cb53c03 100644 --- a/src-tauri/src/tests/contract_shape_tests.rs +++ b/src-tauri/src/tests/contract_shape_tests.rs @@ -109,3 +109,90 @@ fn read_balance_explorer_path_returns_frontend_snake_case() { assert_eq!(wire["locked_confirmed"], 0); assert_eq!(wire["locked_unconfirmed"], 0); } + +// --- The frontend's copy of the per-network RPC ports --- + +/// `src/lib/utils.ts::defaultNodeRpcUrl` re-spells hsd's per-network loopback +/// RPC ports so Settings can show one as a placeholder. Its doc used to ask +/// whoever changed [`Network::default_rpc_url`] to remember it, which is a plea +/// rather than a guard — exactly the drift this module exists to catch. +/// +/// Reading the TypeScript is the cheap half of the fix: a wrong placeholder is +/// cosmetic, so a round-trip to the backend for it would cost more than the +/// bug, but the two tables still have to agree. +#[test] +fn the_frontend_default_rpc_urls_match_the_backend_port_table() { + use crate::noncustodial::network::Network; + + let src = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/lib/utils.ts"), + ) + .expect("src/lib/utils.ts should be readable from the crate"); + + let body = src + .split_once("export function defaultNodeRpcUrl(") + .expect("defaultNodeRpcUrl should exist in src/lib/utils.ts") + .1; + + for network in [ + Network::Main, + Network::Testnet, + Network::Regtest, + Network::Simnet, + ] { + let expected = network.default_rpc_url(); + assert!( + body.contains(&format!("\"{expected}\"")), + "{network:?}: src/lib/utils.ts should offer {expected}; \ + update defaultNodeRpcUrl to match Network::default_rpc_url" + ); + } +} + +// --- The frontend's list of value-re-homing name actions --- + +/// `src/components/ActivityView.tsx::NAME_COVENANT_ACTIONS` names the actions +/// whose covenant output re-homes a name's locked value onto the wallet's own +/// coin, so the Amount cell shows it as information rather than a spend. +/// +/// Which actions belong there is the screen's decision — it is deliberately a +/// subset, since OPEN, REVOKE and CLAIM move no locked value. The spelling is +/// not: these are the labels `classify_tx` emits, and renaming one on the Rust +/// side would leave the set silently never matching, changing what the Amount +/// column shows with nothing failing. +#[test] +fn the_activity_view_name_actions_are_labels_the_backend_emits() { + let backend = [ + "open", "bid", "reveal", "redeem", "register", "update", "renew", "transfer", "finalize", + "revoke", "claim", "other", + ]; + + let src = std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/components/ActivityView.tsx"), + ) + .expect("ActivityView.tsx should be readable from the crate"); + let set = src + .split_once("const NAME_COVENANT_ACTIONS = new Set([") + .expect("NAME_COVENANT_ACTIONS should exist") + .1 + .split_once("]);") + .expect("its literal should be closed") + .0; + + let listed: Vec = set + .split(',') + .filter_map(|s| { + let s = s.trim().trim_matches('"'); + (!s.is_empty()).then(|| s.to_string()) + }) + .collect(); + assert!(!listed.is_empty(), "failed to parse the set"); + + for action in &listed { + assert!( + backend.contains(&action.as_str()), + "ActivityView lists '{action}', which classify_tx never emits — \ + a label was renamed on one side only" + ); + } +} diff --git a/src-tauri/src/tests/daemon_ctl_cmd_tests.rs b/src-tauri/src/tests/daemon_ctl_cmd_tests.rs index 287198e9..8860c45a 100644 --- a/src-tauri/src/tests/daemon_ctl_cmd_tests.rs +++ b/src-tauri/src/tests/daemon_ctl_cmd_tests.rs @@ -26,6 +26,7 @@ use crate::commands::daemon_ctl::{ use crate::db; use crate::tests::command_helpers::create_test_state; use crate::AppState; +use serial_test::serial; use std::collections::HashMap; use std::path::PathBuf; use tauri::test::{mock_builder, mock_context, noop_assets}; @@ -101,6 +102,7 @@ async fn is_background_sync_enabled_false_when_setting_is_gibberish() { // on unix is a permission check, not a signal delivery). Safe in all envs. #[tokio::test] +#[serial(hsd_home)] async fn is_daemon_alive_returns_bool_without_panic() { let alive = is_daemon_alive().await.unwrap(); // We can't assert a specific value: it depends on whether the developer @@ -109,6 +111,7 @@ async fn is_daemon_alive_returns_bool_without_panic() { } #[test] +#[serial(hsd_home)] fn check_daemon_alive_returns_bool_without_panic() { let alive = check_daemon_alive(); let _: bool = alive; @@ -117,6 +120,7 @@ fn check_daemon_alive_returns_bool_without_panic() { // --- ensure_daemon_if_enabled -------------------------------------------- #[test] +#[serial(hsd_home)] fn ensure_daemon_if_enabled_skips_when_disabled() { // enabled=false → early return, never calls spawn_daemon. Purely // observable-by-not-panicking; and it never touches the PID file. @@ -126,6 +130,7 @@ fn ensure_daemon_if_enabled_skips_when_disabled() { } #[test] +#[serial(hsd_home)] fn ensure_daemon_if_enabled_defaults_to_enabled_when_setting_absent() { // No setting present → BACKGROUND_SYNC_DEFAULT="1" fallback → enabled. // Will attempt spawn_daemon; find_daemon_binary fails in test env; the @@ -135,6 +140,7 @@ fn ensure_daemon_if_enabled_defaults_to_enabled_when_setting_absent() { } #[test] +#[serial(hsd_home)] fn ensure_daemon_if_enabled_attempts_spawn_when_enabled() { // enabled=true and daemon-not-alive → spawn_daemon → find_daemon_binary // → Err → logged and swallowed. If the developer HAS a daemon running, @@ -152,6 +158,7 @@ fn ensure_daemon_if_enabled_attempts_spawn_when_enabled() { // PATH. We accept either outcome. #[test] +#[serial(hsd_home)] fn spawn_daemon_either_short_circuits_or_errors_cleanly() { match spawn_daemon() { Ok(()) => { diff --git a/src-tauri/src/tests/history_cmd_tests.rs b/src-tauri/src/tests/history_cmd_tests.rs index 5a3b5eae..d8b101d2 100644 --- a/src-tauri/src/tests/history_cmd_tests.rs +++ b/src-tauri/src/tests/history_cmd_tests.rs @@ -14,6 +14,7 @@ //! which reads `node_rpc_url` from DB settings and hits the REST route //! `GET /tx/address/:addr`. +use crate::tests::command_helpers::set_profile_override; use rusqlite::params; use tauri::test::{mock_builder, mock_context, noop_assets}; use tauri::Manager; @@ -50,14 +51,6 @@ fn app_with(conn: rusqlite::Connection) -> tauri::App // =========================================================================== /// Helper: set a per-profile node config override. -fn set_profile_override(conn: &rusqlite::Connection, profile_id: &str, key: &str, value: &str) { - conn.execute( - "INSERT INTO profile_settings (profile_id, key, value) VALUES (?1, ?2, ?3) - ON CONFLICT(profile_id, key) DO UPDATE SET value = excluded.value", - rusqlite::params![profile_id, key, value], - ) - .unwrap(); -} #[tokio::test] async fn history_uses_active_profile_override() { diff --git a/src-tauri/src/tests/ledger_hid_transport_tests.rs b/src-tauri/src/tests/ledger_hid_transport_tests.rs new file mode 100644 index 00000000..905aab5b --- /dev/null +++ b/src-tauri/src/tests/ledger_hid_transport_tests.rs @@ -0,0 +1,40 @@ +//! Tests for `providers::ledger::hid_transport` helpers that need no device. +//! +//! `status_word_message` turns an APDU status word into the sentence the user +//! reads when a Ledger refuses something. It is the only part of this module +//! that runs without hardware. + +use crate::providers::ledger::hid_transport::status_word_message; + +#[test] +fn a_known_status_word_explains_itself() { + let msg = status_word_message(0x6985); + assert!( + msg.contains("0x6985"), + "the raw word stays in the text: {msg}" + ); + assert!( + msg.contains("user rejected on device"), + "the common case must not read as an unexplained code: {msg}" + ); +} + +#[test] +fn every_mapped_status_word_adds_a_hint() { + for (sw, needle) in [ + (0x6985u16, "user rejected"), + (0x6d00, "instruction not supported"), + (0x6e00, "class not supported"), + (0x6a80, "invalid data"), + (0x5515, "device locked"), + ] { + let msg = status_word_message(sw); + assert!(msg.contains(needle), "0x{sw:04x} lost its hint: {msg}"); + } +} + +#[test] +fn an_unknown_status_word_is_reported_without_inventing_a_reason() { + let msg = status_word_message(0x1234); + assert_eq!(msg, "APDU failed with status 0x1234"); +} diff --git a/src-tauri/src/tests/migration_tests.rs b/src-tauri/src/tests/migration_tests.rs index f9b36707..42227120 100644 --- a/src-tauri/src/tests/migration_tests.rs +++ b/src-tauri/src/tests/migration_tests.rs @@ -68,7 +68,9 @@ fn test_schema_version_tracking() { // 029 (bid_auction_scope: bid index keyed by the auction's OPEN height), // 030 (bid_commitment_auction: commitments carry their auction too), // 031 (rescan_reveal_pairing: reveal values may sit on the wrong bid). - assert_eq!(count, 31); + // 032 (clear_seeded_mainnet_explorer: 009 seeded a mainnet URL that + // outranked the network default on every other network). + assert_eq!(count, 32); } #[test] @@ -194,3 +196,99 @@ fn test_connection_open() { let _ = std::fs::remove_dir_all(&dir); } + +// --- 032: the seeded mainnet explorer is cleared, a chosen one is kept --- + +/// Migration 009 seeded this value; 032 removes exactly it. +const SEEDED_MAINNET_EXPLORER: &str = "https://e.hnsfans.com"; + +#[test] +fn migration_032_clears_the_explorer_url_009_seeded() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run(&conn).unwrap(); + // Re-seed the way an installation that ran the original 009 looks, then + // replay 032 over it: migrations run once, so this is the state such a + // database is already in when the new build starts. + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('explorer_api_url', ?1)", + [SEEDED_MAINNET_EXPLORER], + ) + .unwrap(); + conn.execute_batch(include_str!("../sql/032_clear_seeded_mainnet_explorer.sql")) + .unwrap(); + + let remaining: i64 = conn + .query_row( + "SELECT COUNT(*) FROM settings WHERE key = 'explorer_api_url'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!( + remaining, 0, + "the seeded mainnet URL must be gone so the network default applies" + ); +} + +#[test] +fn migration_032_keeps_an_explorer_url_the_user_chose() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run(&conn).unwrap(); + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('explorer_api_url', ?1)", + ["https://explorer.example.test"], + ) + .unwrap(); + conn.execute_batch(include_str!("../sql/032_clear_seeded_mainnet_explorer.sql")) + .unwrap(); + + let value: String = conn + .query_row( + "SELECT value FROM settings WHERE key = 'explorer_api_url'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(value, "https://explorer.example.test"); +} + +// --- 030's backfill offsets must still describe the consensus they encode --- + +/// Migration 030 backfills `name_start_height` from `reveal_end_height` using a +/// per-network offset spelled out as a literal: 2197, 469, 21. +/// +/// The literals are deliberate. A migration is a one-shot transformation of +/// rows written under the rules of its own time, so deriving the offset from +/// live constants would let a later consensus change silently rewrite history +/// differently. But nothing then tells anyone editing `NameParams` that a +/// migration encodes the old values — which is what this test is for. If it +/// fails, 030 is not wrong; it is a record of what was true, and the failure +/// says the rules have moved since. +#[test] +fn migration_030_offsets_match_the_name_params_they_were_derived_from() { + use crate::noncustodial::network::Network; + + // reveal_end = start + (tree_interval + 1) + bidding_period + reveal_period + let offset = |n: Network| { + let p = n.name_params(); + (p.tree_interval + 1 + p.bidding_period + p.reveal_period) as i64 + }; + + assert_eq!(offset(Network::Main), 2197, "mainnet offset in 030"); + assert_eq!(offset(Network::Testnet), 469, "testnet offset in 030"); + assert_eq!(offset(Network::Regtest), 21, "regtest offset in 030"); + + // The migration's CASE has no arm for simnet. That is safe only while the + // schema refuses to store one. + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::db::migrations::run(&conn).unwrap(); + let rejected = conn.execute( + "INSERT INTO wallet_profiles (id, label, kind, network, account_xpub) + VALUES ('sim', 'Sim', 'watch_only_xpub', 'simnet', 'xpubSIM')", + [], + ); + assert!( + rejected.is_err(), + "030 assumes simnet cannot be stored; the CHECK must keep it out" + ); +} diff --git a/src-tauri/src/tests/mod.rs b/src-tauri/src/tests/mod.rs index 3b78e328..6995175e 100644 --- a/src-tauri/src/tests/mod.rs +++ b/src-tauri/src/tests/mod.rs @@ -20,7 +20,7 @@ mod build_reveal_draft_tests; mod build_transfer_draft_tests; mod build_update_draft_tests; mod chain_scan_tests; -mod command_helpers; +pub(crate) mod command_helpers; mod connection_tests; mod contract_shape_tests; mod csv_cmd_tests; @@ -36,6 +36,7 @@ mod history_cmd_tests; mod hsd_extra_tests; mod hsd_parity_tests; mod hsd_types_tests; +mod ledger_hid_transport_tests; mod live_node_it; mod migration_tests; mod mock_node_rpc; diff --git a/src-tauri/src/tests/name_capabilities_tests.rs b/src-tauri/src/tests/name_capabilities_tests.rs index da9e3912..cb465762 100644 --- a/src-tauri/src/tests/name_capabilities_tests.rs +++ b/src-tauri/src/tests/name_capabilities_tests.rs @@ -64,17 +64,9 @@ fn ctx( #[test] fn task_state_available_no_pending_open() { let state = derive_auction_task_state( + &NameActionContext::default(), "AVAILABLE", false, - false, - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -84,17 +76,12 @@ fn task_state_available_no_pending_open() { #[test] fn task_state_available_with_pending_open() { let state = derive_auction_task_state( + &NameActionContext { + has_pending_open: true, + ..Default::default() + }, "AVAILABLE", false, - false, - false, - false, - false, - None, - None, - true, // has_pending_open - false, - None, None, Network::Main, ); @@ -104,17 +91,9 @@ fn task_state_available_with_pending_open() { #[test] fn task_state_empty_phase_treated_as_available() { let state = derive_auction_task_state( + &NameActionContext::default(), "", false, - false, - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -124,17 +103,9 @@ fn task_state_empty_phase_treated_as_available() { #[test] fn task_state_opening_phase() { let state = derive_auction_task_state( + &NameActionContext::default(), "OPENING", false, - false, - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -144,17 +115,13 @@ fn task_state_opening_phase() { #[test] fn task_state_bidding_with_commitment() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: true, + has_bid_coin: false, + ..Default::default() + }, "BIDDING", false, - true, // has_bid_commitment - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -166,17 +133,9 @@ fn task_state_bidding_with_commitment() { #[test] fn task_state_bidding_without_commitment() { let state = derive_auction_task_state( + &NameActionContext::default(), "BIDDING", false, - false, - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -186,17 +145,12 @@ fn task_state_bidding_without_commitment() { #[test] fn task_state_reveal_no_commitment_returns_unavailable() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_coin: false, + ..Default::default() + }, "REVEAL", false, - false, // no commitment - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -206,18 +160,15 @@ fn task_state_reveal_no_commitment_returns_unavailable() { #[test] fn task_state_reveal_with_broadcasted_draft() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: true, + has_bid_coin: true, + reveal_draft_status: Some("broadcasted".to_string()), + ..Default::default() + }, "REVEAL", false, - true, - true, - false, - false, - None, None, - false, - false, - None, - Some("broadcasted"), Network::Main, ); assert_eq!(state, AuctionTaskState::RevealBroadcastPending); @@ -226,18 +177,15 @@ fn task_state_reveal_with_broadcasted_draft() { #[test] fn task_state_reveal_with_broadcast_pending_draft() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: true, + has_bid_coin: true, + reveal_draft_status: Some("broadcast_pending".to_string()), + ..Default::default() + }, "REVEAL", false, - true, - true, - false, - false, - None, - None, - false, - false, None, - Some("broadcast_pending"), Network::Main, ); assert_eq!(state, AuctionTaskState::RevealBroadcastPending); @@ -246,18 +194,15 @@ fn task_state_reveal_with_broadcast_pending_draft() { #[test] fn task_state_reveal_with_confirmed_draft() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: true, + has_bid_coin: true, + reveal_draft_status: Some("confirmed".to_string()), + ..Default::default() + }, "REVEAL", false, - true, - true, - false, - false, - None, None, - false, - false, - None, - Some("confirmed"), Network::Main, ); assert_eq!(state, AuctionTaskState::RevealDoneWaitingForClose); @@ -267,18 +212,16 @@ fn task_state_reveal_with_confirmed_draft() { fn task_state_reveal_with_dropped_draft_and_unspent_bid_coin() { // Dropped draft but bid coin still unspent → ReadyToReveal (can retry). let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: true, + has_bid_coin: true, + has_reveal_coin: false, + reveal_draft_status: Some("dropped".to_string()), + ..Default::default() + }, "REVEAL", false, - true, - true, // has_bid_coin - false, - false, - None, None, - false, - false, - None, - Some("dropped"), Network::Main, ); assert_eq!(state, AuctionTaskState::ReadyToReveal); @@ -288,17 +231,14 @@ fn task_state_reveal_with_dropped_draft_and_unspent_bid_coin() { fn task_state_reveal_with_txid_and_spent_bid_coin() { // reveal_txid set but bid coin spent (cross-device reveal) → done. let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: true, + has_reveal_coin: false, + reveal_txid: Some("abc123".to_string()), + ..Default::default() + }, "REVEAL", false, - true, - false, // bid coin spent - false, - false, - None, - None, - false, - false, - Some("abc123"), None, Network::Main, ); @@ -308,17 +248,15 @@ fn task_state_reveal_with_txid_and_spent_bid_coin() { #[test] fn task_state_closed_owns_name_unregistered() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: false, + has_owner_coin: true, + owner_covenant_type: Some(2), + ..Default::default() + }, "CLOSED", - true, // owns_name - false, - false, - false, - true, // has_owner_coin - Some(2), // COV_OPEN < COV_REGISTER - None, - false, - false, - None, + true, + // COV_OPEN < COV_REGISTER None, Network::Main, ); @@ -328,17 +266,14 @@ fn task_state_closed_owns_name_unregistered() { #[test] fn task_state_closed_owns_name_already_registered() { let state = derive_auction_task_state( + &NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(6), + ..Default::default() + }, "CLOSED", true, - false, - false, - false, - true, - Some(6), // COV_REGISTER - None, - false, - false, - None, + // COV_REGISTER None, Network::Main, ); @@ -348,18 +283,15 @@ fn task_state_closed_owns_name_already_registered() { #[test] fn task_state_closed_owns_name_registered_expiring_soon() { let state = derive_auction_task_state( + &NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(6), + has_pending_open: false, + ..Default::default() + }, "CLOSED", true, - false, - false, - false, - true, - Some(6), - Some(15.0), // days_until_expire = 15 (below 30-day threshold) - false, - false, - None, - None, + Some(15.0), Network::Main, ); assert_eq!(state, AuctionTaskState::ExpiringSoon); @@ -369,17 +301,12 @@ fn task_state_closed_owns_name_registered_expiring_soon() { fn task_state_closed_owns_name_no_coin_synced() { // Owned per explorer but coin not synced locally. let state = derive_auction_task_state( + &NameActionContext { + owner_covenant_type: None, + ..Default::default() + }, "CLOSED", true, - false, - false, - false, - false, // no owner coin - None, - None, - false, - false, - None, None, Network::Main, ); @@ -389,18 +316,15 @@ fn task_state_closed_owns_name_no_coin_synced() { #[test] fn task_state_closed_lost_has_reveal_coin() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: false, + has_reveal_coin: true, + has_owner_coin: false, + ..Default::default() + }, "CLOSED", - false, // doesn't own - false, - false, - true, // has_reveal_coin (losing bid) false, None, - None, - false, - false, - None, - None, Network::Main, ); assert_eq!(state, AuctionTaskState::LostNeedsRedeem); @@ -411,17 +335,14 @@ fn task_state_recorded_transfer() { // hsd leaves the state at CLOSED while a transfer is pending — there is // no TRANSFER state — so the transfer flag is what decides this. let state = derive_auction_task_state( + &NameActionContext { + has_owner_coin: true, + owner_covenant_type: Some(COV_TRANSFER as i64), + transfer_has_items: Some(true), + ..Default::default() + }, "CLOSED", true, - false, - false, - false, - true, - Some(COV_TRANSFER as i64), - None, - false, - true, - None, None, Network::Main, ); @@ -431,17 +352,9 @@ fn task_state_recorded_transfer() { #[test] fn task_state_revoked_phase() { let state = derive_auction_task_state( + &NameActionContext::default(), "REVOKED", false, - false, - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -451,17 +364,12 @@ fn task_state_revoked_phase() { #[test] fn task_state_unknown_phase_owned() { let state = derive_auction_task_state( + &NameActionContext { + has_bid_commitment: false, + ..Default::default() + }, "UNKNOWN_PHASE", - true, // owns_name - false, - false, - false, - false, - None, - None, - false, - false, - None, + true, None, Network::Main, ); @@ -471,17 +379,9 @@ fn task_state_unknown_phase_owned() { #[test] fn task_state_unknown_phase_not_owned() { let state = derive_auction_task_state( + &NameActionContext::default(), "UNKNOWN_PHASE", false, - false, - false, - false, - false, - None, - None, - false, - false, - None, None, Network::Main, ); @@ -1243,3 +1143,38 @@ fn conservative_no_evidence() { assert_eq!(caps.reveal_txid, None); assert_eq!(caps.bid_value_doos, None); } + +// --- The one predicate the ownership rule is spelled with --- + +#[test] +fn only_a_register_or_later_covenant_counts_as_registered() { + use crate::noncustodial::covenants::is_registered_owner_covenant; + use crate::noncustodial::sync::{ + COV_BID, COV_FINALIZE, COV_OPEN, COV_REDEEM, COV_REGISTER, COV_RENEW, COV_REVEAL, + COV_REVOKE, COV_TRANSFER, COV_UPDATE, + }; + + // Not registered: the auction covenants, and a REDEEM of a losing bid. + for cov in [COV_OPEN, COV_BID, COV_REVEAL, COV_REDEEM] { + assert!( + !is_registered_owner_covenant(Some(cov as i64)), + "covenant {cov} must not read as a registered name" + ); + } + // Registered: REGISTER and everything a registered name can become. + for cov in [ + COV_REGISTER, + COV_UPDATE, + COV_RENEW, + COV_TRANSFER, + COV_FINALIZE, + COV_REVOKE, + ] { + assert!( + is_registered_owner_covenant(Some(cov as i64)), + "covenant {cov} must read as a registered name" + ); + } + // No owner coin is not registered either — the conservative answer. + assert!(!is_registered_owner_covenant(None)); +} diff --git a/src-tauri/src/tests/names_action_context_tests.rs b/src-tauri/src/tests/names_action_context_tests.rs index b0b09ea5..07907549 100644 --- a/src-tauri/src/tests/names_action_context_tests.rs +++ b/src-tauri/src/tests/names_action_context_tests.rs @@ -1188,3 +1188,25 @@ mod rpc_injected_tests { ); } } + +// --- A name that cannot be hashed is an error, not an empty answer --- + +#[test] +fn an_invalid_name_is_refused_rather_than_read_as_nothing_to_reveal() { + // The coin lookups are keyed by the name's hash. Hashing an invalid name + // fails, and the old fallback substituted a zero hash — which matches no + // coin, so every name that reached it reported no reveal coins and no + // owner coin. That is the same answer as "you have nothing to redeem" on + // a wallet that may have money locked up. + let conn = test_db(); + seed_profile(&conn); + seed_derived_address(&conn, ADDRESS, 0, 0); + + let Err(err) = find_name_action_context(&conn, PROFILE, "NotAName", None) else { + panic!("an unhashable name must not resolve to an empty context"); + }; + assert!( + format!("{err:?}").contains("NotAName"), + "the error should name what it refused, got: {err:?}" + ); +} diff --git a/src-tauri/src/tests/names_cmd_tests.rs b/src-tauri/src/tests/names_cmd_tests.rs index 20f86a12..491c5567 100644 --- a/src-tauri/src/tests/names_cmd_tests.rs +++ b/src-tauri/src/tests/names_cmd_tests.rs @@ -1404,12 +1404,12 @@ async fn batch_capabilities_respects_wallet_profile_isolation() { |r| r.get(0), ) .unwrap(); - // Wallet A owns "nameforA"; wallet B owns "nameforB" — each row's + // Wallet A owns "namefora"; wallet B owns "nameforb" — each row's // owner_address only matches its own wallet's derived address. conn.execute( "INSERT INTO tracked_name_states (wallet_profile_id, name, name_hash_hex, state, owner_txid, owner_vout, owner_address, height) - VALUES (?1, 'nameforA', 'aabb', 'CLOSED', ?2, 0, ?3, 100)", + VALUES (?1, 'namefora', 'aabb', 'CLOSED', ?2, 0, ?3, 100)", rusqlite::params![ &a, "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", @@ -1420,7 +1420,7 @@ async fn batch_capabilities_respects_wallet_profile_isolation() { conn.execute( "INSERT INTO tracked_name_states (wallet_profile_id, name, name_hash_hex, state, owner_txid, owner_vout, owner_address, height) - VALUES (?1, 'nameforB', 'ccdd', 'CLOSED', ?2, 0, ?3, 100)", + VALUES (?1, 'nameforb', 'ccdd', 'CLOSED', ?2, 0, ?3, 100)", rusqlite::params![ &b, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", @@ -1432,7 +1432,7 @@ async fn batch_capabilities_respects_wallet_profile_isolation() { }; let app = mock_app_with(state); - let names = vec!["nameforA".to_string(), "nameforB".to_string()]; + let names = vec!["namefora".to_string(), "nameforb".to_string()]; let as_a = names::get_names_action_capabilities(app.state(), names.clone(), Some(profile_a)) .await @@ -1444,7 +1444,7 @@ async fn batch_capabilities_respects_wallet_profile_isolation() { .collect(); assert_eq!( owned_by_a, - vec!["nameforA"], + vec!["namefora"], "profile A must only see its own name as owned" ); @@ -1458,7 +1458,7 @@ async fn batch_capabilities_respects_wallet_profile_isolation() { .collect(); assert_eq!( owned_by_b, - vec!["nameforB"], + vec!["nameforb"], "profile B must only see its own name as owned" ); } diff --git a/src-tauri/src/tests/node_config_tests.rs b/src-tauri/src/tests/node_config_tests.rs index aa7fb5bc..18fa8ebe 100644 --- a/src-tauri/src/tests/node_config_tests.rs +++ b/src-tauri/src/tests/node_config_tests.rs @@ -12,10 +12,9 @@ use rusqlite::Connection; +use crate::db::queries::get_profile_settings; use crate::error::AppError; -use crate::noncustodial::node_config::{ - effective_node_config_for_profile, get_profile_settings, DEFAULT_NODE_RPC_URL, -}; +use crate::noncustodial::node_config::{default_node_rpc_url, effective_node_config_for_profile}; use crate::noncustodial::rpc::ChainSource; /// A migrated in-memory DB with the migrations the resolver depends on, plus @@ -95,7 +94,7 @@ fn missing_profile_is_not_found() { fn falls_back_to_builtin_default_when_nothing_set() { let conn = db_with_profile(); let cfg = effective_node_config_for_profile(&conn, "p1").unwrap(); - assert_eq!(cfg.node_rpc_url, DEFAULT_NODE_RPC_URL); + assert_eq!(cfg.node_rpc_url, default_node_rpc_url()); assert_eq!(cfg.node_rpc_api_key, ""); assert_eq!(cfg.chain_source, ChainSource::LocalNode); assert!(!cfg.from_override, "pure default must not be an override"); @@ -221,7 +220,7 @@ fn for_profile_falls_through_to_global_then_default() { // No override, no global url -> built-in default; global key present. set_global(&conn, "node_rpc_api_key", "global-key"); let client = NodeRpcClient::for_profile(&conn, "p1").unwrap(); - assert_eq!(client.node_url(), DEFAULT_NODE_RPC_URL); + assert_eq!(client.node_url(), default_node_rpc_url()); assert_eq!(client.api_key(), "global-key"); } @@ -245,3 +244,59 @@ fn from_effective_config_carries_resolved_fields() { assert_eq!(client.node_url(), "https://node.example:12037"); assert_eq!(client.api_key(), "k"); } + +// --- Which flag realign is allowed to consult (ADR-001, N11) --- + +#[test] +fn an_override_equal_to_global_is_still_the_users_choice() { + // The flag used to mean "differs from global", so pinning a profile to the + // value global happens to hold today read as no override at all — and + // realign would then rewrite a URL the user had deliberately set. Global + // can also change under them afterwards. + let conn = db_with_profile(); + set_global(&conn, "node_rpc_url", "http://127.0.0.1:12037"); + set_override(&conn, "p1", "node_rpc_url", "http://127.0.0.1:12037"); + let cfg = effective_node_config_for_profile(&conn, "p1").unwrap(); + assert!(cfg.from_override, "choosing a value is choosing it"); + assert!(cfg.url_from_override, "realign must leave this URL alone"); +} + +#[test] +fn overriding_the_chain_source_alone_leaves_the_url_realignable() { + // Realign rewrites the URL and nothing else. A profile that only chose its + // chain source has expressed no opinion about the port, so a stale global + // loopback must still be fixable. + let conn = db_with_profile(); + set_global(&conn, "node_rpc_url", "http://127.0.0.1:12037"); + set_override(&conn, "p1", "chain_source", "remote_node"); + let cfg = effective_node_config_for_profile(&conn, "p1").unwrap(); + assert!(cfg.from_override, "the profile did choose something"); + assert!( + !cfg.url_from_override, + "the URL still came from the global fallback" + ); +} + +#[test] +fn a_per_profile_setting_outside_the_node_tuple_is_not_a_node_override() { + // `profile_settings` is a general per-profile store. A key that says + // nothing about which node this profile talks to must not make the node + // config read as overridden — and so must not disable realign. + let conn = db_with_profile(); + set_override(&conn, "p1", "some_unrelated_preference", "yes"); + let cfg = effective_node_config_for_profile(&conn, "p1").unwrap(); + assert!(!cfg.from_override); + assert!(!cfg.url_from_override); +} + +#[test] +fn an_empty_url_override_does_not_protect_the_url_from_realign() { + // An empty override is "no meaningful choice" for resolution, so it must + // be "no meaningful choice" for realign too, or the two disagree about + // whose URL is in play. + let conn = db_with_profile(); + set_global(&conn, "node_rpc_url", "http://127.0.0.1:12037"); + set_override(&conn, "p1", "node_rpc_url", ""); + let cfg = effective_node_config_for_profile(&conn, "p1").unwrap(); + assert!(!cfg.url_from_override); +} diff --git a/src-tauri/src/tests/node_lifecycle_tests.rs b/src-tauri/src/tests/node_lifecycle_tests.rs index babb9294..93b63479 100644 --- a/src-tauri/src/tests/node_lifecycle_tests.rs +++ b/src-tauri/src/tests/node_lifecycle_tests.rs @@ -26,6 +26,7 @@ use std::io::Write; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use serial_test::serial; use tauri::test::{mock_builder, mock_context, noop_assets}; use tauri::Manager; @@ -215,6 +216,7 @@ async fn spawn_then_up_server(height: i64) -> (mockito::ServerGuard, mockito::Mo // start_hsd: spawn succeeds, RPC comes up → connected, child alive. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn start_hsd_spawns_binary_and_reports_connected_when_rpc_answers() { // Fail the adoption probe so we actually spawn, then answer the loop probe. let (server, _fail, _up) = spawn_then_up_server(42).await; @@ -259,6 +261,7 @@ async fn start_hsd_spawns_binary_and_reports_connected_when_rpc_answers() { // `/regtest` here would double-nest to `/regtest/regtest`. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn start_hsd_passes_regtest_flag_and_scoped_prefix_for_regtest_profile() { let (server, _fail, _up) = spawn_then_up_server(1).await; let h = Harness::new(FakeMode::StayAlive, "8.5.0"); @@ -355,6 +358,7 @@ async fn start_hsd_passes_regtest_flag_and_scoped_prefix_for_regtest_profile() { // branches; success (connected) confirms the spawn still works. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn start_hsd_spawns_in_spv_mode_on_testnet() { let (server, _fail, _up) = spawn_then_up_server(7).await; let h = Harness::new(FakeMode::StayAlive, "8.5.0"); @@ -391,6 +395,7 @@ async fn start_hsd_spawns_in_spv_mode_on_testnet() { // version gate — it warns and proceeds to spawn rather than blocking. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn start_hsd_proceeds_when_version_unparseable() { let (server, _fail, _up) = spawn_then_up_server(3).await; // A version string with no numeric semver prefix → parse_hsd_version None. @@ -412,6 +417,7 @@ async fn start_hsd_proceeds_when_version_unparseable() { // "already running on this data directory" branch, log tail surfaced. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn start_hsd_surfaces_data_dir_lock_when_child_dies_with_lock() { let h = Harness::new(FakeMode::DieLock, "8.5.0"); // No RPC ever answers → the wait-loop notices the child exited. @@ -440,6 +446,7 @@ async fn start_hsd_surfaces_data_dir_lock_when_child_dies_with_lock() { // "hsd exited on startup" branch with the log tail. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn start_hsd_surfaces_generic_exit_when_child_dies() { let h = Harness::new(FakeMode::DieError, "8.5.0"); let app = app_with(conn_for(&h, NO_RPC)); @@ -464,6 +471,7 @@ async fn start_hsd_surfaces_generic_exit_when_child_dies() { // start_hsd: a too-old hsd version is refused BEFORE any spawn. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn start_hsd_refuses_too_old_version_before_spawn() { // Version 7.x < the 8.0.0 minimum. `die-lock` mode would fail loudly if we // ever reached spawn — but we must not. @@ -487,6 +495,7 @@ async fn start_hsd_refuses_too_old_version_before_spawn() { // stop_hsd: kills the child we spawned and clears the handle + alive flag. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn stop_hsd_kills_spawned_child_and_clears_handle() { let (server, _fail, _up) = spawn_then_up_server(9).await; let h = Harness::new(FakeMode::StayAlive, "8.5.0"); @@ -527,11 +536,13 @@ async fn stop_hsd_kills_spawned_child_and_clears_handle() { // and assert they end up under a `_noindex-backup-*` dir. // =========================================================================== #[tokio::test] +#[serial(hsd_home)] async fn resync_hsd_chain_backs_up_chain_data_and_respawns() { let (server, _fail, _up) = spawn_then_up_server(1).await; let h = Harness::new(FakeMode::StayAlive, "8.5.0"); - // Mainnet layout: blocks/, chain/, tree/ directly under the prefix. No - // active profile → active_profile_network defaults to mainnet. + // Mainnet layout: blocks/, chain/, tree/ directly under the prefix — the + // harness seeds a mainnet profile, and mainnet is the one network whose + // data dir is the prefix root rather than a subdirectory of it. for sub in ["blocks", "chain", "tree"] { let p = h.data_dir().join(sub); std::fs::create_dir_all(&p).unwrap(); @@ -582,3 +593,27 @@ async fn resync_hsd_chain_backs_up_chain_data_and_respawns() { stop_hsd(app.state()).await.expect("stop ok"); } + +/// `resync_hsd_chain` moves chain data out of the way, so it must know which +/// network's data that is. Without an active profile it refuses, for the same +/// reason `start_hsd` does: guessing mainnet would back up — and then re-sync +/// over — a directory belonging to a network the user is not on. +#[tokio::test] +#[serial(hsd_home)] +async fn resync_hsd_chain_refuses_without_an_active_profile() { + let (server, _fail, _up) = spawn_then_up_server(1).await; + let h = Harness::new(FakeMode::StayAlive, "8.5.0"); + let conn = conn_for(&h, &server.url()); + // Drop the profile the harness seeds, leaving the wallet with none. + db::queries::set_setting(&conn, "active_wallet_profile_id", "").unwrap(); + conn.execute("DELETE FROM wallet_profiles", []).unwrap(); + + let app = app_with(conn); + let err = resync_hsd_chain(app.state()) + .await + .expect_err("no profile means no chain to re-sync"); + assert!( + format!("{err}").contains("no active wallet profile"), + "the refusal should say what is missing, got: {err}" + ); +} diff --git a/src-tauri/src/tests/node_rpc_injected_tests.rs b/src-tauri/src/tests/node_rpc_injected_tests.rs index 761fae46..2cf4e88a 100644 --- a/src-tauri/src/tests/node_rpc_injected_tests.rs +++ b/src-tauri/src/tests/node_rpc_injected_tests.rs @@ -15,9 +15,9 @@ use serde_json::json; +use crate::commands::node_readiness::node_tip_height_if_synced_with_client; use crate::commands::read::{ - node_tip_height_if_synced_with_client, read_block_info_with_client, - read_name_records_with_client, read_tx_info_with_client, + read_block_info_with_client, read_name_records_with_client, read_tx_info_with_client, }; use crate::error::AppError; use crate::noncustodial::node_rpc::NodeRpc; diff --git a/src-tauri/src/tests/node_status_tests.rs b/src-tauri/src/tests/node_status_tests.rs index 7d79d80c..66707719 100644 --- a/src-tauri/src/tests/node_status_tests.rs +++ b/src-tauri/src/tests/node_status_tests.rs @@ -2,6 +2,7 @@ //! whether we spawned a child. With no node reachable, `connected` is false and //! `process_alive` is false — and it never falsely reports a connection. +use crate::tests::command_helpers::set_profile_override; use tauri::test::{mock_builder, mock_context, noop_assets}; use tauri::Manager; @@ -62,7 +63,7 @@ async fn node_status_reports_disconnected_when_no_node() { // --- is_node_ready_for_local_reads ------------------------------------------- -use crate::commands::read::is_node_ready_for_local_reads; +use crate::commands::node_readiness::is_node_ready_for_local_reads; #[tokio::test] async fn local_reads_not_ready_when_not_connected() { @@ -76,7 +77,7 @@ async fn local_reads_not_ready_when_not_connected() { // --- node_ready_from_settings (the settings-based gate used by the background // sync thread, which has no State) -------------------------------- -use crate::commands::read::node_ready_from_settings; +use crate::commands::node_readiness::node_ready_from_settings; /// Build a settings map pointing the node RPC at a mockito server URL. fn settings_for_url(url: &str) -> std::collections::HashMap { @@ -501,7 +502,7 @@ async fn probe_and_update_sets_flag_true_when_node_answers() { // --- node_tip_height_if_synced_from_settings_with_network --------------------- -use crate::commands::read::node_tip_height_if_synced_from_settings_with_network; +use crate::commands::node_readiness::node_tip_height_if_synced_from_settings_with_network; #[tokio::test] async fn synced_with_matching_network_returns_height() { @@ -619,7 +620,7 @@ async fn synced_with_no_chain_in_response_skips_check() { // Per-profile node config resolution for readiness probe (ADR-001) // =========================================================================== -use crate::commands::read::node_tip_height_if_synced_from_profile_with_network; +use crate::commands::node_readiness::node_tip_height_if_synced_from_profile_with_network; /// Helper: create a temp file-backed DB (in-memory won't work because the /// async probe re-opens the connection from a path — see the Send bound @@ -640,15 +641,6 @@ fn temp_db_conn() -> (String, rusqlite::Connection) { (path_str.to_string(), conn) } -/// Helper: set a per-profile node config override. -fn set_profile_override(conn: &rusqlite::Connection, profile_id: &str, key: &str, value: &str) { - conn.execute( - "INSERT OR REPLACE INTO profile_settings (profile_id, key, value) VALUES (?1, ?2, ?3)", - rusqlite::params![profile_id, key, value], - ) - .unwrap(); -} - /// Helper: create a test profile with minimal required fields. fn create_test_profile(conn: &rusqlite::Connection, profile_id: &str, network: &str) { conn.execute( @@ -783,7 +775,7 @@ async fn probe_respects_network_mismatch_with_profile_override() { // Per-profile readiness gate (boolean wrapper) // =========================================================================== -use crate::commands::read::node_ready_from_profile; +use crate::commands::node_readiness::node_ready_from_profile; #[tokio::test] async fn node_ready_from_profile_returns_true_when_synced_and_network_matches() { @@ -1256,6 +1248,7 @@ async fn node_status_reflects_seeded_profile_network_testnet() { // =========================================================================== #[tokio::test] +#[serial_test::serial(hsd_home)] async fn node_status_data_dir_defaults_to_home_dot_hsd_when_prefix_unset() { let conn = blank_conn(); db::queries::set_setting(&conn, "node_rpc_url", "http://127.0.0.1:1").unwrap(); @@ -1427,6 +1420,40 @@ use crate::commands::node::{ PrefixMigration, }; +#[tokio::test] +async fn probe_refuses_a_profile_whose_node_config_will_not_resolve() { + // ADR-001: "a per-profile override that fails is not a fallback — it is a + // user-facing error". The probe used to swallow the failure and describe + // whatever node global happens to name, which on another chain is a + // confident answer about somebody else's node. + let mut server_global = mockito::Server::new_async().await; + let m = server_global + .mock("POST", "/") + .match_body(mockito::Matcher::Regex("getblockchaininfo".into())) + .with_body( + r#"{"result":{"blocks":99,"headers":99,"verificationprogress":1.0},"error":null,"id":1}"#, + ) + .expect(0) + .create_async() + .await; + + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys = ON;").unwrap(); + db::migrations::run(&conn).unwrap(); + db::queries::set_setting(&conn, "node_rpc_url", &server_global.url()).unwrap(); + // An active id pointing at a profile that does not exist: resolution + // returns NotFound, which is exactly the "will not resolve" case. + db::queries::set_setting(&conn, "active_wallet_profile_id", "ghost").unwrap(); + + let app = app_with(conn); + let state = app.state::(); + assert!( + !crate::commands::node::probe_and_update(&state).await, + "an unresolvable profile config must read as no node, not as global's" + ); + m.assert_async().await; +} + /// A unique scratch dir under the OS temp dir, cleaned on drop. struct Scratch(std::path::PathBuf); impl Scratch { @@ -1628,3 +1655,65 @@ fn migrate_adopts_legacy_nested_subdir_into_scoped_root() { "moved, not copied" ); } + +// --- Which hsd log lines mean the node failed to start --- + +use crate::commands::node::is_fatal_startup_line; + +#[test] +fn routine_sync_noise_is_not_a_startup_failure() { + // The line that made this predicate necessary: hsd logs peer failures with + // the word "Error" throughout a healthy sync. + for benign in [ + "[debug] (net) Error: Socket Error: ECONNREFUSED (1.2.3.4:12038)", + "[warning] (peer) Error: Peer timed out.", + "[info] (chain) Block 000000 (1) added to chain.", + "[debug] (mempool) Added transaction to mempool.", + ] { + assert!( + !is_fatal_startup_line(benign), + "should not read as a startup failure: {benign}" + ); + } +} + +#[test] +fn the_known_fatal_shapes_are_recognised() { + for fatal in [ + "[error] (node) Cannot retroactively enable address indexing.", + "Error: bind EADDRINUSE 0.0.0.0:12037", + "Error: listen EADDRINUSE: address already in use :::12037", + "Uncaught Error: Could not open database.", + "[error] (chain) cannot open chain database", + ] { + assert!( + is_fatal_startup_line(fatal), + "should read as a startup failure: {fatal}" + ); + } +} + +#[test] +fn a_data_dir_path_that_merely_contains_bind_is_not_a_failure() { + // `bind` used to be matched on its own, so a prefix like this — or any + // "binding"/"rebinding" progress line — reported a healthy node as broken. + // The address-in-use case it existed for arrives as `bind EADDRINUSE`, + // which is still caught above. + assert!(!is_fatal_startup_line( + "[info] (node) Opening /Volumes/bind-drive/hsd-data" + )); + assert!(!is_fatal_startup_line( + "[debug] (chain) Rebinding handlers." + )); +} + +#[test] +fn a_broad_matcher_is_documented_rather_than_quietly_wrong() { + // `Cannot ` is deliberately broad: this predicate only runs when the RPC is + // already unreachable, so over-reporting relabels "still starting" on a node + // that is down either way, while under-reporting leaves a broken node + // silent. This pins that it is a choice, not an oversight. + assert!(is_fatal_startup_line( + "[info] (chain) Cannot find checkpoint." + )); +} diff --git a/src-tauri/src/tests/paid_swaps_cmd_tests.rs b/src-tauri/src/tests/paid_swaps_cmd_tests.rs index d62b06f5..2bf3ebc5 100644 --- a/src-tauri/src/tests/paid_swaps_cmd_tests.rs +++ b/src-tauri/src/tests/paid_swaps_cmd_tests.rs @@ -4,6 +4,7 @@ //! `find_payment_output` and `verify_paid_transfer_with_client` are tested //! elsewhere (inline in the source file and in `node_rpc_injected_tests.rs`). +use crate::tests::command_helpers::set_profile_override; use tauri::test::{mock_builder, mock_context, noop_assets}; use tauri::Manager; @@ -612,14 +613,6 @@ fn add_active_profile(conn: &rusqlite::Connection, id: &str, network: &str) { } /// Helper: set a per-profile node config override. -fn set_profile_override(conn: &rusqlite::Connection, profile_id: &str, key: &str, value: &str) { - conn.execute( - "INSERT INTO profile_settings (profile_id, key, value) VALUES (?1, ?2, ?3) - ON CONFLICT(profile_id, key) DO UPDATE SET value = excluded.value", - rusqlite::params![profile_id, key, value], - ) - .unwrap(); -} #[tokio::test] async fn claim_uses_active_profile_override_over_global() { diff --git a/src-tauri/src/tests/provider_hnsfans_tests.rs b/src-tauri/src/tests/provider_hnsfans_tests.rs index 199080f1..0ea1935d 100644 --- a/src-tauri/src/tests/provider_hnsfans_tests.rs +++ b/src-tauri/src/tests/provider_hnsfans_tests.rs @@ -863,3 +863,75 @@ async fn test_explorer_provider_trait_delegates_get_address_txids() { // exercises the delegation. let _ = result; } + +// --- The seeded mainnet explorer must not leak onto another network (032) --- + +/// The value migration 009 used to seed into `explorer_api_url`. Installations +/// that ran that version still carry it, and an explicit setting outranks the +/// network default, so it has to be refused rather than trusted. +const SEEDED_MAINNET_EXPLORER: &str = "https://e.hnsfans.com"; + +#[test] +fn seeded_mainnet_explorer_is_refused_off_mainnet() { + let mut settings = std::collections::HashMap::new(); + settings.insert( + "explorer_api_url".to_string(), + SEEDED_MAINNET_EXPLORER.to_string(), + ); + + for network in [ + crate::noncustodial::network::Network::Testnet, + crate::noncustodial::network::Network::Regtest, + crate::noncustodial::network::Network::Simnet, + ] { + assert!( + crate::providers::explorer_client_from_settings(&settings, network).is_none(), + "{network:?} must not fall back to the mainnet explorer" + ); + } +} + +#[test] +fn seeded_mainnet_explorer_is_refused_off_mainnet_with_a_trailing_slash() { + // The same URL with hsd's habitual trailing slash is the same explorer. + let mut settings = std::collections::HashMap::new(); + settings.insert( + "explorer_api_url".to_string(), + format!("{SEEDED_MAINNET_EXPLORER}/"), + ); + assert!(crate::providers::explorer_client_from_settings( + &settings, + crate::noncustodial::network::Network::Regtest, + ) + .is_none()); +} + +#[test] +fn the_mainnet_explorer_is_still_used_on_mainnet() { + let mut settings = std::collections::HashMap::new(); + settings.insert( + "explorer_api_url".to_string(), + SEEDED_MAINNET_EXPLORER.to_string(), + ); + assert!(crate::providers::explorer_client_from_settings( + &settings, + crate::noncustodial::network::Network::Main, + ) + .is_some()); +} + +#[test] +fn a_users_own_explorer_url_is_honoured_off_mainnet() { + // Only the known mainnet explorer is refused. The wallet cannot tell which + // chain a private explorer serves, so it takes the user at their word. + let mut settings = std::collections::HashMap::new(); + settings.insert( + "explorer_api_url".to_string(), + "https://explorer.example.test".to_string(), + ); + assert!(crate::providers::explorer_client_from_settings( + &settings, + crate::noncustodial::network::Network::Regtest, + ) + .is_some()); +} diff --git a/src-tauri/src/tests/read_cmd_tests.rs b/src-tauri/src/tests/read_cmd_tests.rs index b756408f..8490eae1 100644 --- a/src-tauri/src/tests/read_cmd_tests.rs +++ b/src-tauri/src/tests/read_cmd_tests.rs @@ -10,6 +10,7 @@ use rusqlite::params; use tauri::test::{mock_builder, mock_context, noop_assets}; use tauri::Manager; +use crate::commands::node_readiness::is_node_ready_for_local_reads; use crate::commands::read::{ discover_owned_names, empty_name_bids_response, merge_name_bids, read_auction_position_names, read_balance, read_name_bids, read_name_info, read_name_records, read_names, read_transactions, @@ -23,9 +24,8 @@ use crate::AppState; // block so it's easy to see the read-tests baseline vs. the new coverage // harness in a single file. use crate::commands::read::{ - get_resource, is_node_ready_for_local_reads, list_receive_addresses, merge_indexed_bids, - read_block_info, read_renewals, read_tx_info, repair_owned_names, resolve_profile, - reveal_next_receive_address, + get_resource, list_receive_addresses, merge_indexed_bids, read_block_info, read_renewals, + read_tx_info, repair_owned_names, resolve_profile, reveal_next_receive_address, }; // --------------------------------------------------------------------------- @@ -2072,7 +2072,7 @@ async fn repair_owned_names_via_node_records_error_on_rpc_failure() { fn estimate_persisted_height_returns_none_when_no_signal() { let conn = empty_db(); add_profile(&conn, "H1", "regtest"); - let h = crate::commands::read::estimate_persisted_height(&conn, "H1").unwrap(); + let h = crate::commands::node_readiness::estimate_persisted_height(&conn, "H1").unwrap(); assert!(h.is_none()); } @@ -2088,7 +2088,7 @@ fn estimate_persisted_height_reads_from_profile_last_synced_height() { [], ) .unwrap(); - let h = crate::commands::read::estimate_persisted_height(&conn, "H2") + let h = crate::commands::node_readiness::estimate_persisted_height(&conn, "H2") .unwrap() .unwrap(); // Value may be aged slightly (>=12345); the important thing is it was read. @@ -2123,7 +2123,7 @@ fn estimate_persisted_height_prefers_max_across_sources() { [], ) .unwrap(); - let h = crate::commands::read::estimate_persisted_height(&conn, "H3") + let h = crate::commands::node_readiness::estimate_persisted_height(&conn, "H3") .unwrap() .unwrap(); // Max of (25000 - 5000) and 15000 is 20000 (plus small aging drift). @@ -2654,7 +2654,7 @@ fn estimate_persisted_height_reads_node_shaped_raw_json_info_field() { rusqlite::params![raw], ) .unwrap(); - let h = crate::commands::read::estimate_persisted_height(&conn, "EN1") + let h = crate::commands::node_readiness::estimate_persisted_height(&conn, "EN1") .unwrap() .unwrap(); // 50000 - 10000 == 40000, plus small aging drift (rows aged in blocks). @@ -2703,7 +2703,7 @@ fn estimate_persisted_height_skips_malformed_and_stats_less_rows() { ) .unwrap(); // With no valid signals AND no last_synced_height set, the result is None. - let h = crate::commands::read::estimate_persisted_height(&conn, "EN2").unwrap(); + let h = crate::commands::node_readiness::estimate_persisted_height(&conn, "EN2").unwrap(); assert!(h.is_none(), "all rows should be skipped; got {h:?}"); } @@ -2738,7 +2738,7 @@ fn estimate_persisted_height_picks_max_across_multiple_rows() { ) .unwrap(); } - let h = crate::commands::read::estimate_persisted_height(&conn, "EN3") + let h = crate::commands::node_readiness::estimate_persisted_height(&conn, "EN3") .unwrap() .unwrap(); // Max implied height is 40000 (row 2). Aging drift only adds — never subtracts. @@ -2761,7 +2761,7 @@ fn estimate_persisted_height_ignores_null_last_synced_height() { [], ) .unwrap(); - let h = crate::commands::read::estimate_persisted_height(&conn, "EN4").unwrap(); + let h = crate::commands::node_readiness::estimate_persisted_height(&conn, "EN4").unwrap(); assert!(h.is_none()); } @@ -3195,3 +3195,42 @@ async fn read_block_info_uses_per_profile_node_override() { o_hash.assert_async().await; o_block.assert_async().await; } + +// --- An unknown network selects no explorer, rather than mainnet's --- + +/// `Network` derives `Default = Main`, so a network the wallet cannot read used +/// to resolve to mainnet and send a live request to the mainnet explorer for a +/// wallet that may be on another chain. These pin the refusal on the three +/// reads that pick an explorer. +#[tokio::test] +async fn read_name_bids_serves_nothing_when_its_profiles_network_is_unreadable() { + let conn = empty_db(); + // An explicit explorer URL is configured, so the only thing standing + // between the command and a cross-network read is the network check. + db::queries::set_setting(&conn, "explorer_api_url", "http://127.0.0.1:1").unwrap(); + // No profile row at all: the id the command is handed does not resolve. + let app = app_with(conn); + let val = read_name_bids(app.state(), "foo".into(), Some("ghost".into())) + .await + .expect("an unreadable network is an empty answer, not an error"); + assert_eq!( + val["bids"].as_array().map(|b| b.len()).unwrap_or(0), + 0, + "no explorer means no bids, not mainnet's bids" + ); +} + +#[tokio::test] +async fn read_name_info_refuses_when_the_active_profiles_network_is_unreadable() { + let conn = empty_db(); + db::queries::set_setting(&conn, "explorer_api_url", "http://127.0.0.1:1").unwrap(); + // No active profile: nothing says which chain this wallet is on. + let app = app_with(conn); + let err = read_name_info(app.state(), "foo".into()) + .await + .expect_err("with no explorer available this surfaces, it does not guess"); + assert!( + format!("{err}").contains("No explorer is available"), + "should say the explorer is unavailable, got: {err}" + ); +} diff --git a/src-tauri/src/tests/tx_lifecycle_tests.rs b/src-tauri/src/tests/tx_lifecycle_tests.rs index 73ccfe97..b3adbc29 100644 --- a/src-tauri/src/tests/tx_lifecycle_tests.rs +++ b/src-tauri/src/tests/tx_lifecycle_tests.rs @@ -1143,6 +1143,53 @@ async fn write_capability_allows_when_synced_indexed_and_unlocked() { ); } +/// A node on another chain must not seed this profile's cache: neither the +/// cursor coin selection reads for coinbase maturity nor the tracked name +/// states. The refusal happens before the coin fetch, so the coin route is +/// never asked. +#[tokio::test] +async fn sync_wallet_state_refuses_a_node_on_another_chain() { + let mut server = mockito::Server::new_async().await; + let _bi = server + .mock("POST", "/") + .match_body(mockito::Matcher::Regex("getblockchaininfo".into())) + .with_body(r#"{"result":{"chain":"regtest","blocks":150000},"error":null,"id":1}"#) + .create_async() + .await; + let coins = server + .mock("GET", mockito::Matcher::Regex("^/coin/address/".into())) + .with_body("[]") + .expect(0) + .create_async() + .await; + let conn = seeded_conn(&server.url(), 2_000_000); + let app = app_with(conn); + + let err = sync_wallet_state(app.state(), None) + .await + .expect_err("a regtest node must not sync a mainnet profile"); + assert!( + matches!(&err, AppError::InvalidInput(msg) if msg.contains("refusing to sync")), + "expected the cross-network refusal, got {err:?}" + ); + coins.assert_async().await; + let state = app.state::(); + let cursor: Option = state + .db + .lock() + .unwrap() + .query_row( + "SELECT last_height FROM sync_cursors WHERE wallet_profile_id = ?1", + [PROFILE], + |r| r.get(0), + ) + .ok(); + assert_eq!( + cursor, None, + "the foreign height must never land in sync_cursors" + ); +} + #[tokio::test] async fn sync_wallet_state_reports_unreachable_node_softly() { // An unreachable node is NOT an error — reads come from the explorer; we just diff --git a/src/components/ActivityView.tsx b/src/components/ActivityView.tsx index f185ad24..637e01bf 100644 --- a/src/components/ActivityView.tsx +++ b/src/components/ActivityView.tsx @@ -1,3 +1,4 @@ +import { explorerCoversNetwork } from "../lib/openExternal"; import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useActionHistory } from "../queries/read"; @@ -52,6 +53,13 @@ export const FALLBACK_META = { label: "Other", variant: "default" as const }; // Actions whose covenant output re-homes the name's locked value onto the // wallet's own new coin. For these, the Amount cell shows the locked value as // an informational "⤷ N" (not a spend) with a tooltip; net flow stays 0. +// +// Deliberately a subset of the action labels the backend emits — "open", +// "revoke" and "claim" move no locked value — so which actions belong here is +// this screen's decision, not the backend's. What the backend does own is the +// spelling: a renamed label would silently stop matching and quietly change +// what the Amount cell shows. A Rust test reads this list and fails if any +// entry is not a label `classify_tx` can produce. const NAME_COVENANT_ACTIONS = new Set([ "bid", "reveal", @@ -88,7 +96,7 @@ export function ActivityView() { const { data: rows = [], isLoading, isError, error } = useActionHistory(); const { data: drafts = [] } = useTxDrafts(); const { data: profile } = useActiveProfile(); - const isMainnet = profile?.network === "mainnet"; + const isMainnet = explorerCoversNetwork(profile?.network); const qc = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); @@ -316,15 +324,35 @@ export function ActivityView() { /** * Map a MergedRow status to a badge variant and display label. */ -function statusBadge(status: string): { +function statusBadge( + status: string, + /** + * For an `onchain` row, whether a block has included it. The other statuses + * carry that in the status itself, so it is ignored there. + */ + onchain: { confirmed: boolean; height: number | null }, +): { variant: "default" | "success" | "warning" | "error" | "info"; label: string; /** What the status means and what it is waiting on. */ hint: string; } { if (status === "onchain") { - // Handled by the caller (confirmed/pending badge). - return { variant: "default", label: "Onchain", hint: "Seen on-chain." }; + // "Onchain" alone is not a state a user can act on: a row the node has + // seen is either in a block or waiting for one. This used to return a + // placeholder the caller was expected to know to discard, which is a + // function answering a question it has the information to answer. + return onchain.confirmed + ? { + variant: "success", + label: "Confirmed", + hint: `Mined into a block${onchain.height != null ? ` (#${onchain.height})` : ""}.`, + } + : { + variant: "warning", + label: "Pending", + hint: "Seen by the node but not in a block yet.", + }; } if (status === "confirmed") { return { variant: "success", label: "Confirmed", hint: "Mined into a block. Done." }; @@ -463,20 +491,11 @@ export function ActivityRow({ const showNameValue = NAME_COVENANT_ACTIONS.has(row.action) && row.valueDoos === 0 && row.nameValueDoos != null; - const badge = statusBadge(row.status); - // For onchain-only rows, use the confirmed/pending badge; for drafts, - // use the status badge. - const badgeVariant = - row.status === "onchain" ? (row.confirmed ? "success" : "warning") : badge.variant; - const badgeLabel = - row.status === "onchain" ? (row.confirmed ? "Confirmed" : "Pending") : badge.label; - // The height is already its own column; the badge's hint explains the state. - const badgeHint = - row.status === "onchain" - ? row.confirmed - ? `Mined into a block${row.height != null ? ` (#${row.height})` : ""}.` - : "Seen by the node but not in a block yet." - : badge.hint; + const badge = statusBadge(row.status, { + confirmed: row.confirmed, + height: row.height ?? null, + }); + const { variant: badgeVariant, label: badgeLabel, hint: badgeHint } = badge; const linkClass = "text-blue-500 hover:text-blue-700 hover:underline cursor-pointer"; diff --git a/src/components/AddWalletForm.tsx b/src/components/AddWalletForm.tsx index 69d9466d..4f67550e 100644 --- a/src/components/AddWalletForm.tsx +++ b/src/components/AddWalletForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { NetworkSelect } from "./ui/NetworkSelect"; import { useUiStore } from "../stores/ui"; import { useSecureCreateWallet, @@ -101,15 +102,7 @@ export function AddWalletForm({ const NetworkPicker = (
- +
); diff --git a/src/components/AuctionsView.tsx b/src/components/AuctionsView.tsx index 898669c2..66c37080 100644 --- a/src/components/AuctionsView.tsx +++ b/src/components/AuctionsView.tsx @@ -1,7 +1,12 @@ import { useState, useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useActiveProfile } from "../queries/wallet"; -import { useReadNames, useNamesActionCapabilities, useAuctionPositions } from "../queries/read"; +import { + useReadNames, + useNamesActionCapabilities, + useAuctionPositions, + nameCapabilitiesQueryKey, +} from "../queries/read"; import { auctionPhase, taskSummaryFromCapabilities, @@ -172,11 +177,11 @@ export function AuctionsView() { // result the table already fetched, so the modal opens already showing the // same task-state badge as the row the user clicked — no fetch-window flash // where it would fall back to the raw on-chain phase and visibly contradict - // the table. Keyed identically to `useNameActionCapabilities` in read.ts: - // ["read","nameCapabilities", profileId, name]. + // the table. The key comes from the query that owns it, so the two cannot + // drift apart. const rowCaps = capsByName.get(name); if (rowCaps) { - qc.setQueryData(["read", "nameCapabilities", activeProfileId, name], rowCaps); + qc.setQueryData(nameCapabilitiesQueryKey(activeProfileId, name), rowCaps); } setManageName(name); }; diff --git a/src/components/BatchBidModal.tsx b/src/components/BatchBidModal.tsx index c3f0712f..d7b4527e 100644 --- a/src/components/BatchBidModal.tsx +++ b/src/components/BatchBidModal.tsx @@ -50,6 +50,7 @@ export function BatchBidModal({ open, onClose, activeProfileId }: BatchBidModalP const [pendingDraft, setPendingDraft] = useState<{ id: string; feeDoos: number; + amountDoos: number; names: string[]; } | null>(null); @@ -96,6 +97,7 @@ export function BatchBidModal({ open, onClose, activeProfileId }: BatchBidModalP setPendingDraft({ id: draft.id, feeDoos: draft.summary?.feeDoos ?? 0, + amountDoos: draft.summary?.sendTotalDoos ?? 0, names: biddableNames, }); setStep("confirm"); @@ -150,6 +152,7 @@ export function BatchBidModal({ open, onClose, activeProfileId }: BatchBidModalP action="bid" names={pendingDraft.names} estimatedFeeDoos={pendingDraft.feeDoos} + amountDoos={pendingDraft.amountDoos} onConfirm={handleBatchConfirm} onCancel={handleBack} /> diff --git a/src/components/BatchConfirmModal.tsx b/src/components/BatchConfirmModal.tsx index f8335660..99110e40 100644 --- a/src/components/BatchConfirmModal.tsx +++ b/src/components/BatchConfirmModal.tsx @@ -9,6 +9,12 @@ export interface BatchConfirmModalProps { action: "bid" | "renew" | "reveal" | "redeem" | "finalize" | "transfer"; names: string[]; estimatedFeeDoos: number; + /** + * What the transaction moves, summed over every output except change (B9). + * A batch reveal or redeem carries one output per bid, so this is the + * number that tells the user how much is actually in play. + */ + amountDoos: number; /** * For `action === "transfer"`, the single shared recipient the whole batch * will be transferred to. Rendered as a "To: …" line in the summary panel @@ -23,7 +29,8 @@ export interface BatchConfirmModalProps { /** * Batch confirmation modal: shows the user what they're about to do (action + - * name count + estimated fee) and asks for confirmation before proceeding. + * name count + amount + estimated fee) and asks for confirmation before + * proceeding. * Names list is collapsible (collapsed by default for large batches). */ export function BatchConfirmModal({ @@ -31,6 +38,7 @@ export function BatchConfirmModal({ action, names, estimatedFeeDoos, + amountDoos, recipient, onConfirm, onCancel, @@ -67,6 +75,11 @@ export function BatchConfirmModal({ {names.length !== 1 ? "s" : ""}. +
+ + Amount: {formatHns(amountDoos)} HNS + +
Estimated fee: ~{formatHns(estimatedFeeDoos / 1_000_000)} HNS diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 162dbe7a..720a6c91 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -81,7 +81,11 @@ export function Layout() { - + ℹ️ diff --git a/src/components/NameActionsModal.tsx b/src/components/NameActionsModal.tsx index b4b2f335..03aff847 100644 --- a/src/components/NameActionsModal.tsx +++ b/src/components/NameActionsModal.tsx @@ -36,7 +36,7 @@ import { formatHns, formatHnsShort } from "../lib/utils"; import { Tooltip } from "./ui/Tooltip"; import { displayName } from "../lib/idn"; import { WatchlistToggle } from "./WatchlistToggle"; -import { explorerNameUrl, openExternal } from "../lib/openExternal"; +import { explorerCoversNetwork, explorerNameUrl, openExternal } from "../lib/openExternal"; import { auctionPhase, nextTransition, @@ -59,11 +59,12 @@ import type { NameActionCapability } from "../types"; * The modal is task-driven: it uses backend capability data to show the most * relevant action, with clear disabled reasons when actions aren't available. * - * Task 13 (F6): this file is the thin orchestrator — it owns all state, the - * mutation runner, and the modal layout; the widgets live in - * `./name-actions/` (`GuidedAction`, `BidForm`, `DnsRecordsEditor`, - * `OwnershipActions`) and receive state + callbacks as props. + * This file is the thin orchestrator — it owns all state, the mutation + * runner, and the modal layout; the widgets live in `./name-actions/` and + * receive state + callbacks as props. */ +const RECORDS_NOT_FRESH_REASON = "Waiting for a fresh read of the current on-chain records"; + export function NameActionsModal({ name, open, @@ -105,8 +106,6 @@ export function NameActionsModal({ finalize: useNameAction("build_finalize_draft"), cancel: useNameAction("build_cancel_draft"), revoke: useNameAction("build_revoke_draft"), - finalizeWithPayment: useNameAction("build_finalize_with_payment_draft"), - sellWithPayment: useNameAction("create_paid_swap_offer"), }; // Bid inputs in HNS (human-readable), converted to doos on submit. @@ -350,19 +349,18 @@ export function NameActionsModal({ // leading an auction does not — the owner coin is still a REVEAL. caps?.nameIsRegistered === true; - // A wallet that has bid and is waiting for the window has nothing left to - // submit. This no longer reaches the advanced toggle — `sections.anyLive` - // decides that — and survives only to keep the "unlock to sign" notice off a - // modal with nothing to sign. - const alreadyBidWaiting = caps?.taskState === "waitingForBidding"; + // The auction exists but its bidding window has not opened: the name is in + // OPENING, or an OPEN this wallet broadcast is still waiting for a block. + // (It once also meant "this wallet has already bid", back when a second bid + // was refused; several bids are allowed now, so BIDDING never reports this.) + const biddingNotOpenYet = caps?.taskState === "waitingForBidding"; // Whether the modal actually offers something to sign/broadcast right now. - // `hasRelevantActions` includes a phase-based fallback that is true during - // BIDDING even after THIS wallet has already bid — in that state every - // action is caps-disabled and there is nothing left to submit, so the - // "unlock to sign" notice would be pointless. Gate signable UI on this - // instead of the looser `hasRelevantActions`. - const hasSignableActions = hasRelevantActions && !alreadyBidWaiting; + // `hasRelevantActions` includes a phase-based fallback that is true before + // the bidding window opens, when every action is caps-disabled and there is + // nothing to submit — the "unlock to sign" notice would be pointless there. + // Gate signable UI on this instead of the looser `hasRelevantActions`. + const hasSignableActions = hasRelevantActions && !biddingNotOpenYet; // One rule for the toggle: open it only when something behind it can be // acted on. Every "is this phase meaningful?" special case this used to @@ -397,7 +395,18 @@ export function NameActionsModal({ return actionReason(cap); }; - const run = async (label: string, builder: () => Promise<{ id: string }>) => { + // What happens once the transaction is out: by default the modal reports + // it and closes. Reveal stays open instead, to show the pending card. + const closeAfterBroadcast = (label: string) => (txid: string) => { + showToast(`${label} broadcast — ${txid.slice(0, 12)}…`, "success"); + onClose(); + }; + + const run = async ( + label: string, + builder: () => Promise<{ id: string }>, + afterBroadcast: (txid: string) => void = closeAfterBroadcast(label), + ) => { if (!profile) return; setBusy(label); let draft: { id: string }; @@ -415,10 +424,9 @@ export function NameActionsModal({ const result = await exec.run(draft.id, profile.id, unlocked); // Broadcast succeeded — the draft is now owned by the chain, not us. pendingDraftRef.current = null; - showToast(`${label} broadcast — ${result.txid.slice(0, 12)}…`, "success"); qc.invalidateQueries({ queryKey: ["wallet"] }); qc.invalidateQueries({ queryKey: ["read"] }); - onClose(); + afterBroadcast(result.txid); } catch (e) { // exec.run() tags its rejection with which leg of unlock→sign→broadcast // threw (see useExecuteDraft) — thread that through to the toast. @@ -437,44 +445,18 @@ export function NameActionsModal({ } }; - // Reveal confirm-and-broadcast: builds the reveal draft, runs the - // unlock→sign→broadcast pipeline, then stays in the modal (shows the - // pending card) rather than closing. On success, sets the optimistic txid - // so the card renders immediately (before the next caps poll). - const handleRevealConfirm = async () => { - if (!profile) return; - setBusy("REVEAL"); - let draft: { id: string }; - try { - draft = await build.reveal.mutateAsync({ name }); - } catch (e) { - showToast(mapError(e, "build"), "error"); - setBusy(null); - return; - } - pendingDraftRef.current = draft.id; - try { - const result = await exec.run(draft.id, profile.id, unlocked); - // Success: stay in the modal, show the pending card. - pendingDraftRef.current = null; - setOptimisticRevealTxid(result.txid); - setRevealConfirming(false); - qc.invalidateQueries({ queryKey: ["wallet"] }); - qc.invalidateQueries({ queryKey: ["read"] }); - } catch (e) { - showToast(mapError(unwrapStaged(e), stageOf(e)), "error"); - // On failure, stay in the confirm panel so the user can retry. - // A pre-broadcast cancel/failure orphans the reveal draft — discard it so - // a retry isn't blocked. Keep it only if broadcast may be in flight. - if (stageOf(e) !== "broadcast") { - await discardPendingDraft(); - } else { - pendingDraftRef.current = null; - } - } finally { - setBusy(null); - } - }; + // Reveal stays in the modal after broadcast (shows the pending card) and + // sets the optimistic txid so the card renders before the next caps poll. + // On failure it stays in the confirm panel so the user can retry. + const handleRevealConfirm = () => + run( + "REVEAL", + () => build.reveal.mutateAsync({ name }), + (txid) => { + setOptimisticRevealTxid(txid); + setRevealConfirming(false); + }, + ); // Recover a lost bid_commitments row from the on-chain BID coin + a // user-remembered bid amount (see `recover_bid_commitment`). Needs only the @@ -613,10 +595,9 @@ export function NameActionsModal({ } >
- {/* Explorer link (mainnet only — Shakeshift indexes no other chain, so - the link would 404) + watchlist toggle */} + {/* Explorer link + watchlist toggle */}
- {profile?.network === "mainnet" ? ( + {explorerCoversNetwork(profile?.network) ? (
@@ -164,15 +164,11 @@ function ConnectionChoice({ apiKeyTestId="remote-api-key-input" actionsLayout="stack" /> - +
@@ -673,58 +660,18 @@ function UpdateNotificationSettings({ form: Record; updateField: (key: string, value: string) => void; }) { - const [permission, setPermission] = useState(null); - const [requesting, setRequesting] = useState(false); - const enabled = settingToBool(form.update_notify_enabled); - - useEffect(() => { - checkNotificationPermission().then(setPermission); - }, []); - - const onToggle = async (checked: boolean) => { - updateField("update_notify_enabled", boolToSetting(checked)); - if (!checked) return; - setRequesting(true); - try { - const status = await requestNotificationPermission(); - setPermission(status); - } finally { - setRequesting(false); - } - }; - return ( -
- - - {enabled && ( - <> - {permission === "denied" && ( -
- OS notifications are blocked for this app. Enable them in your system notification - settings — otherwise you won't get an alert when a new version is available. -
- )} - {permission === "unsupported" && ( -
- OS notifications aren't available outside the desktop app. -
- )} - {requesting &&
Requesting permission…
} - - )} -
+ otherwise you won't get an alert when a new version is available. + } + form={form} + updateField={updateField} + /> ); } @@ -913,28 +860,13 @@ function NodeControl({ dirty, hsdPathConfigured }: { dirty: boolean; hsdPathConf const connected = status?.connected ?? false; const processAlive = status?.process_alive ?? false; - // "Synced" = chain tip reached (applied blocks caught up to best header). - // verificationProgress can plateau just under 1.0 (e.g. ~0.9997 on regtest), - // so a headers match is the ground truth and progress only corroborates it. - // Mirrors the backend `chain_synced` rule in src-tauri/.../rpc.rs — keep the - // two in sync, or the label will disagree with what reads actually do. const height = status?.height ?? null; const headers = status?.headers ?? null; const progress = status?.verification_progress ?? null; - // Loose floor below which a height == headers match is distrusted as - // "headers not yet at the real tip" (the ~8%-verified case). - const HEADERS_MATCH_PROGRESS_FLOOR = 0.999; - const synced = - headers != null && headers > 0 - ? // Headers known: blocks caught up to the tip, and (when reported) - // progress clears the loose floor so a far-behind node stays unsynced. - height != null && - height >= headers && - (progress == null || progress >= HEADERS_MATCH_PROGRESS_FLOOR) - : // No headers to compare — fall back to the progress-only gate. - progress != null - ? progress >= 0.9999 - : true; + // The backend's own verdict, not a second copy of the rule. This used to be + // re-derived here from height/headers/progress with a comment asking whoever + // changed one to remember the other — which is the drift, written down. + const synced = status?.synced ?? false; const pct = progress != null ? Math.floor(progress * 1000) / 10 @@ -1103,74 +1035,36 @@ function NotificationSettings({ form: Record; updateField: (key: string, value: string) => void; }) { - const [permission, setPermission] = useState(null); - const [requesting, setRequesting] = useState(false); - const enabled = settingToBool(form.deadline_notify_enabled); - - useEffect(() => { - checkNotificationPermission().then(setPermission); - }, []); - - const onToggle = async (checked: boolean) => { - updateField("deadline_notify_enabled", boolToSetting(checked)); - if (!checked) return; - setRequesting(true); - try { - const status = await requestNotificationPermission(); - setPermission(status); - } finally { - setRequesting(false); - } - }; - return ( -
- - - {enabled && ( + - {permission === "denied" && ( -
- OS notifications are blocked for this app. Enable them in your system notification - settings — deadlines will still show in-app, but you won't get an alert when the - app isn't open. -
- )} - {permission === "unsupported" && ( -
- OS notifications aren't available outside the desktop app. -
- )} - {requesting &&
Requesting permission…
} - -
- updateField("deadline_notify_reveal_lead_blocks", e.target.value)} - placeholder="144" - /> - updateField("deadline_notify_renewal_lead_days", e.target.value)} - placeholder="30" - /> -
+ deadlines will still show in-app, but you won't get an alert when the app isn't + open. - )} -
+ } + form={form} + updateField={updateField} + > +
+ updateField("deadline_notify_reveal_lead_blocks", e.target.value)} + placeholder="144" + /> + updateField("deadline_notify_renewal_lead_days", e.target.value)} + placeholder="30" + /> +
+
); } @@ -1181,81 +1075,37 @@ function WatchlistNotificationSettings({ form: Record; updateField: (key: string, value: string) => void; }) { - const [permission, setPermission] = useState(null); - const [requesting, setRequesting] = useState(false); - const enabled = settingToBool(form.watchlist_notify_enabled); - - useEffect(() => { - checkNotificationPermission().then(setPermission); - }, []); - - const onToggle = async (checked: boolean) => { - updateField("watchlist_notify_enabled", boolToSetting(checked)); - if (!checked) return; - setRequesting(true); - try { - const status = await requestNotificationPermission(); - setPermission(status); - } finally { - setRequesting(false); - } - }; - return ( -
-
+ ); } diff --git a/src/components/WalletView.tsx b/src/components/WalletView.tsx index 7b6d2100..164d7ded 100644 --- a/src/components/WalletView.tsx +++ b/src/components/WalletView.tsx @@ -64,7 +64,7 @@ import { } from "../lib/utils"; import { mergeActivity } from "../lib/activity"; import { mapError } from "../lib/errors"; -import { explorerAddressUrl } from "../lib/openExternal"; +import { explorerAddressUrl, explorerCoversNetwork } from "../lib/openExternal"; import { useUiStore } from "../stores/ui"; import { QRCodeSVG } from "qrcode.react"; import { ReceiveAddressList } from "./ReceiveAddressList"; @@ -72,6 +72,8 @@ import type { NameActionCapabilities, TxDraftSummary } from "../types"; import { subscribeAction } from "../lib/actionBus"; import { Tooltip } from "./ui/Tooltip"; +type BatchAction = "renew" | "reveal" | "redeem" | "finalize" | "transfer"; + export function WalletView() { const qc = useQueryClient(); const showToast = useUiStore((s) => s.showToast); @@ -170,9 +172,10 @@ export function WalletView() { // Batch confirmation modal state. const [batchModal, setBatchModal] = useState<{ open: boolean; - action: "renew" | "reveal" | "redeem" | "finalize" | "transfer"; + action: BatchAction; names: string[]; feeDoos: number; + amountDoos: number; draftId: string; recipient?: string; } | null>(null); @@ -327,112 +330,66 @@ export function WalletView() { return { canReveal, canRedeem, canFinalize, canTransfer }; }, [selectedNames, nameCaps]); - // Batch renew: build a single tx with multiple renewal covenants, sign, broadcast. // Compute the fee-rate arg once for all batch handlers (null = use setting default). const batchFeeRateArg = parseFeeRateArg(batchFeeRate) ?? undefined; - const handleBatchRenew = async () => { - const names = Array.from(selectedNames); - if (names.length === 0) return; - try { - showToast(`Building batch renew draft…`, "info"); - const draft = await batchRenewMutation.mutateAsync({ names, feeRate: batchFeeRateArg }); - const feeDoos = draft.summary?.feeDoos ?? 0; - setBatchModal({ - open: true, - action: "renew", - names, - feeDoos, - draftId: draft.id, - }); - } catch (e) { - showToast(`Batch renew failed: ${e}`, "error"); - } - }; - - // Batch reveal: build a single tx with multiple REVEAL covenants. - const handleBatchReveal = async () => { - const names = Array.from(selectedNames); - if (names.length === 0) return; - try { - showToast(`Building batch reveal draft…`, "info"); - const draft = await batchRevealMutation.mutateAsync({ names, feeRate: batchFeeRateArg }); - const feeDoos = draft.summary?.feeDoos ?? 0; - setBatchModal({ - open: true, - action: "reveal", - names, - feeDoos, - draftId: draft.id, - }); - } catch (e) { - showToast(`Batch reveal failed: ${e}`, "error"); - } - }; - - // Batch redeem: build a single tx to sweep losing-bid coins. - const handleBatchRedeem = async () => { - const names = Array.from(selectedNames); - if (names.length === 0) return; - try { - showToast(`Building batch redeem draft…`, "info"); - const draft = await batchRedeemMutation.mutateAsync({ names, feeRate: batchFeeRateArg }); - const feeDoos = draft.summary?.feeDoos ?? 0; - setBatchModal({ - open: true, - action: "redeem", - names, - feeDoos, - draftId: draft.id, - }); - } catch (e) { - showToast(`Batch redeem failed: ${e}`, "error"); - } - }; - - // Batch finalize: build a single tx with multiple FINALIZE covenants. - const handleBatchFinalize = async () => { + // Every batch action is the same three steps — build one draft for the + // selection, then hand its fee and amount to the confirm modal — and every + // failure is reported the same way. + const runBatch = async ( + action: BatchAction, + build: () => Promise, + extra: { recipient?: string } = {}, + ) => { const names = Array.from(selectedNames); if (names.length === 0) return; try { - showToast(`Building batch finalize draft…`, "info"); - const draft = await batchFinalizeMutation.mutateAsync({ names, feeRate: batchFeeRateArg }); - const feeDoos = draft.summary?.feeDoos ?? 0; + showToast(`Building batch ${action} draft…`, "info"); + const draft = await build(); setBatchModal({ open: true, - action: "finalize", + action, names, - feeDoos, + feeDoos: draft.summary?.feeDoos ?? 0, + amountDoos: draft.summary?.sendTotalDoos ?? 0, draftId: draft.id, + ...extra, }); } catch (e) { - showToast(`Batch finalize failed: ${e}`, "error"); + showToast(`Batch ${action} failed: ${mapError(e)}`, "error"); } }; + const batchNames = () => Array.from(selectedNames); - const handleBatchTransfer = async () => { - const names = Array.from(selectedNames); + const handleBatchRenew = () => + runBatch("renew", () => + batchRenewMutation.mutateAsync({ names: batchNames(), feeRate: batchFeeRateArg }), + ); + const handleBatchReveal = () => + runBatch("reveal", () => + batchRevealMutation.mutateAsync({ names: batchNames(), feeRate: batchFeeRateArg }), + ); + const handleBatchRedeem = () => + runBatch("redeem", () => + batchRedeemMutation.mutateAsync({ names: batchNames(), feeRate: batchFeeRateArg }), + ); + const handleBatchFinalize = () => + runBatch("finalize", () => + batchFinalizeMutation.mutateAsync({ names: batchNames(), feeRate: batchFeeRateArg }), + ); + const handleBatchTransfer = () => { const recipient = batchRecipient.trim(); - if (names.length === 0 || !recipient) return; - try { - showToast(`Building batch transfer draft…`, "info"); - const draft = await batchTransferMutation.mutateAsync({ - names, - recipient, - feeRate: batchFeeRateArg, - }); - const feeDoos = draft.summary?.feeDoos ?? 0; - setBatchModal({ - open: true, - action: "transfer", - names, - feeDoos, - draftId: draft.id, - recipient, - }); - } catch (e) { - showToast(`Batch transfer failed: ${mapError(e)}`, "error"); - } + if (!recipient) return; + return runBatch( + "transfer", + () => + batchTransferMutation.mutateAsync({ + names: batchNames(), + recipient, + feeRate: batchFeeRateArg, + }), + { recipient }, + ); }; // Confirm a pending batch draft: unlock (if needed) → sign → broadcast. @@ -835,7 +792,7 @@ export function WalletView() { copyLabel="Copy Address" toastLabel="Address" externalUrl={ - profile.network === "mainnet" ? explorerAddressUrl(address) : undefined + explorerCoversNetwork(profile.network) ? explorerAddressUrl(address) : undefined } externalTestId="receive-address-explorer-link" /> @@ -1524,6 +1481,7 @@ export function WalletView() { action={batchModal.action} names={batchModal.names} estimatedFeeDoos={batchModal.feeDoos} + amountDoos={batchModal.amountDoos} recipient={batchModal.recipient} onConfirm={handleBatchConfirm} onCancel={handleBatchCancel} @@ -1543,7 +1501,7 @@ export function WalletView() { height={infoBlock} open={infoBlock != null} onClose={() => setInfoBlock(null)} - isMainnet={profile.network === "mainnet"} + isMainnet={explorerCoversNetwork(profile.network)} /> )} @@ -1552,7 +1510,7 @@ export function WalletView() { txid={infoTx} open={infoTx != null} onClose={() => setInfoTx(null)} - isMainnet={profile.network === "mainnet"} + isMainnet={explorerCoversNetwork(profile.network)} /> )} diff --git a/src/components/__tests__/action-reason-banner.test.tsx b/src/components/__tests__/action-reason-banner.test.tsx index 0792ad8b..45c99a4c 100644 --- a/src/components/__tests__/action-reason-banner.test.tsx +++ b/src/components/__tests__/action-reason-banner.test.tsx @@ -6,57 +6,72 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ActionReasonBanner } from "../name-actions/ActionReasonBanner"; +import { makeProfile, makeSession } from "../../test/fixtures/wallet"; -vi.mock("../../queries/wallet", () => ({ - useActiveProfile: vi.fn(), - useSignerSession: vi.fn(), - useUnlockSigner: vi.fn(), +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), })); -vi.mock("../../stores/ui", () => ({ - useUiStore: vi.fn((selector: (s: { showToast: () => void }) => unknown) => - selector({ showToast: vi.fn() }), - ), -})); -vi.mock("../../lib/errors", () => ({ - mapError: vi.fn((e: unknown) => String(e)), -})); - -import { useActiveProfile, useSignerSession, useUnlockSigner } from "../../queries/wallet"; -/* eslint-disable @typescript-eslint/no-explicit-any */ function signer(unlocked: boolean) { - (useActiveProfile as any).mockReturnValue({ data: { id: "p1" } }); - (useSignerSession as any).mockReturnValue({ data: { unlocked } }); - (useUnlockSigner as any).mockReturnValue({ isPending: false, mutateAsync: vi.fn() }); + invokeMock.mockImplementation((cmd: string) => { + switch (cmd) { + case "list_wallet_profiles": + return Promise.resolve([makeProfile()]); + case "get_signer_session": + return Promise.resolve(makeSession({ unlocked })); + default: + return Promise.reject(new Error(`unexpected command ${cmd}`)); + } + }); } -/* eslint-enable @typescript-eslint/no-explicit-any */ -beforeEach(() => vi.clearAllMocks()); +function renderBanner(reason: string | null) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +/** The button decides once both queries have answered. */ +const sessionLoaded = () => + waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith("list_wallet_profiles", undefined); + expect(invokeMock).toHaveBeenCalledWith("get_signer_session", undefined); + }); + +beforeEach(() => { + invokeMock.mockReset(); +}); describe("ActionReasonBanner", () => { it("renders nothing without a reason", () => { signer(false); - const { container } = render(); + const { container } = renderBanner(null); expect(container.firstChild).toBeNull(); }); - it("puts an Unlock button beside the reason when the wallet is locked", () => { + it("puts an Unlock button beside the reason when the wallet is locked", async () => { signer(false); - render(); + renderBanner("Unlock your wallet to sign transactions."); expect(screen.getByTestId("action-reason")).toHaveTextContent( "Unlock your wallet to sign transactions.", ); - expect(screen.getByTestId("unlock-now")).toBeInTheDocument(); + expect(await screen.findByTestId("unlock-now")).toBeInTheDocument(); }); - it("stays text-only for a reason unlocking cannot fix", () => { + it("stays text-only for a reason unlocking cannot fix", async () => { // Signer already unlocked — the action is blocked by the auction phase, so // offering "Unlock" would be a dead end. signer(true); - render(); + renderBanner("Reveal has not started yet."); + await sessionLoaded(); expect(screen.getByTestId("action-reason")).toHaveTextContent("Reveal has not started yet."); expect(screen.queryByTestId("unlock-now")).toBeNull(); diff --git a/src/components/__tests__/auction-ux.test.tsx b/src/components/__tests__/auction-ux.test.tsx index f62c8cb1..27b18adb 100644 --- a/src/components/__tests__/auction-ux.test.tsx +++ b/src/components/__tests__/auction-ux.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { makeCapabilities } from "../../test/fixtures/capabilities"; import "@testing-library/jest-dom"; import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -476,38 +477,21 @@ describe("NameActionsModal — local bid shown before reveal", () => { }) { return (cmd: string) => { if (cmd === "get_name_action_capabilities") { - return Promise.resolve({ - name: "biddingtld", - phase: "BIDDING", - taskState: "waitingForBidding", - ownsName: false, - nameIsRegistered: false, - transferPending: false, - redeemableRevealCount: 0, - redeemableValueDoos: 0, - hasBidCommitment: opts.hasBidCommitment, - hasRevealCoin: false, - hasOwnerCoin: false, - bidValueDoos: opts.bidValueDoos, - lockupValueDoos: opts.lockupValueDoos ?? null, - canOpen: { allowed: false, reason: null }, - canBid: { allowed: false, reason: "Already bid" }, - canReveal: { allowed: false, reason: "Reveal not open" }, - canRedeem: { allowed: false, reason: null }, - canRegister: { allowed: false, reason: null }, - canUpdate: { allowed: false, reason: null }, - canTransfer: { allowed: false, reason: null }, - canFinalize: { allowed: false, reason: null }, - canCancelTransfer: { allowed: false, reason: null }, - canRenew: { allowed: false, reason: null }, - canRevoke: { allowed: false, reason: null }, - nextActionKey: null, - nextActionLabel: null, - nextActionReason: null, - countdownLabel: null, - countdownBlocks: null, - countdownHours: null, - }); + return Promise.resolve( + makeCapabilities({ + name: "biddingtld", + phase: "BIDDING", + // What the backend derives for BIDDING: more bids are allowed for + // the rest of the window, however many this wallet already holds. + taskState: "readyToBid", + hasBidCommitment: opts.hasBidCommitment, + myBidCount: opts.hasBidCommitment ? 1 : 0, + bidValueDoos: opts.bidValueDoos, + lockupValueDoos: opts.lockupValueDoos ?? null, + canBid: { allowed: true, reason: null }, + canReveal: { allowed: false, reason: "Reveal not open" }, + }), + ); } switch (cmd) { case "list_wallet_profiles": diff --git a/src/components/__tests__/batch-transfer.test.tsx b/src/components/__tests__/batch-transfer.test.tsx index 20ce3338..8f3338c7 100644 --- a/src/components/__tests__/batch-transfer.test.tsx +++ b/src/components/__tests__/batch-transfer.test.tsx @@ -273,6 +273,8 @@ describe("WalletView — batch transfer", () => { await waitFor(() => expect(screen.getByText(/Confirm batch transfer/i)).toBeInTheDocument()); const recipientRow = screen.getByTestId("batch-transfer-recipient"); expect(recipientRow).toHaveTextContent(RECIPIENT); + // B9: the amount is every output except change — 10 HNS in this draft. + expect(screen.getByTestId("batch-amount")).toHaveTextContent("10.000000"); fireEvent.click(screen.getByRole("button", { name: /^Confirm$/i })); diff --git a/src/components/__tests__/name-acquisition.test.tsx b/src/components/__tests__/name-acquisition.test.tsx index b1a16d08..4b4f9c31 100644 --- a/src/components/__tests__/name-acquisition.test.tsx +++ b/src/components/__tests__/name-acquisition.test.tsx @@ -545,14 +545,11 @@ describe("NameActionsModal — guided acquisition flow", () => { expect(screen.getByText("Bid")).toBeInTheDocument(); }); - // Regression: a name whose on-chain phase is still BIDDING but for which - // THIS wallet already placed a bid resolves to taskState `waitingForBidding` - // — but because it IS genuinely bidding (phase BIDDING + a placed bid), the - // badge now unifies with the modal's on-chain phase and reads "Bidding". - // The guided body used to key purely on the raw phase and render a - // "Place a Bid"-flavored panel; it must render the wait-for-reveal panel - // and offer no bid CTA. - it("shows a wait-for-reveal panel (not a bid CTA) for a BIDDING name already bid by this wallet", async () => { + // Regression: a BIDDING name this wallet has already bid on still invites + // another bid. Several independent bids per name are allowed, so the guided + // panel must keep the bid form rather than collapsing to the wait-for-reveal + // panel it used to show once one bid was placed. + it("still offers the bid form for a BIDDING name this wallet has already bid on", async () => { invokeMock.mockImplementation( routeModal( { @@ -574,33 +571,27 @@ describe("NameActionsModal — guided acquisition flow", () => { capabilities: { name: "bidname", phase: "BIDDING", - taskState: "waitingForBidding", + taskState: "readyToBid", ownsName: false, nameIsRegistered: false, transferPending: false, redeemableRevealCount: 0, redeemableValueDoos: 0, hasBidCommitment: true, + myBidCount: 1, hasRevealCoin: false, hasOwnerCoin: false, canOpen: { allowed: false, reason: null }, - canBid: { - allowed: false, - reason: - "you already have a bid commitment for this name (one bid per wallet per name)", - }, + canBid: { allowed: true, reason: null }, canReveal: { allowed: false, reason: "Reveal not open yet" }, canRedeem: { allowed: false, reason: null }, canRegister: { allowed: false, reason: "Phase is BIDDING" }, canUpdate: { allowed: false, reason: null }, canTransfer: { allowed: false, reason: null }, canFinalize: { allowed: false, reason: null }, - nextActionKey: "WAIT", - nextActionLabel: "Wait for Bidding", - // Backend refines this reason for the already-bid BIDDING case - // (see the next_action override in names.rs). The frontend - // no-countdown panel renders the SAME string verbatim. - nextActionReason: "Your bid is placed. Wait for the reveal window to open.", + nextActionKey: "BID", + nextActionLabel: "Place a Bid", + nextActionReason: null, countdownLabel: null, countdownBlocks: null, countdownHours: null, @@ -610,18 +601,12 @@ describe("NameActionsModal — guided acquisition flow", () => { ); render( {}} />, { wrapper: wrapper() }); - // The badge reads "Bidding" (genuine-bidding unification), matching the - // on-chain phase — not "Waiting for Bidding". + // The badge matches the on-chain phase. expect(await screen.findByText("Bidding")).toBeInTheDocument(); expect(screen.queryByText("Waiting for Bidding")).not.toBeInTheDocument(); - // The guided body is the wait-for-reveal panel, not the bid panel. - expect(screen.getByTestId("bidding-waiting")).toBeInTheDocument(); - // Exact unified copy (verbatim match with backend next_action_reason). - expect( - screen.getByText("Your bid is placed. Wait for the reveal window to open."), - ).toBeInTheDocument(); - // No bid-flavored guided copy under the "Bidding" badge. - expect(screen.queryByText("Place a Bid")).not.toBeInTheDocument(); + // The bid form is still offered, holding an existing bid notwithstanding. + expect(screen.getAllByLabelText("Bid (HNS)").length).toBeGreaterThan(0); + expect(screen.getAllByLabelText("Lockup (HNS)").length).toBeGreaterThan(0); }); it("shows Reveal for a REVEAL name", async () => { diff --git a/src/components/__tests__/name-actions-bid-gate.test.tsx b/src/components/__tests__/name-actions-bid-gate.test.tsx index a56d1948..bd11a452 100644 --- a/src/components/__tests__/name-actions-bid-gate.test.tsx +++ b/src/components/__tests__/name-actions-bid-gate.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +import { makeCapabilities } from "../../test/fixtures/capabilities"; import "@testing-library/jest-dom"; import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -39,42 +40,24 @@ const profile = { function route(phase: "OPENING" | "BIDDING", canBidAllowed: boolean) { return (cmd: string) => { if (cmd === "get_name_action_capabilities") { - return Promise.resolve({ - name: "examplename", - phase, - taskState: "none", - ownsName: false, - nameIsRegistered: false, - transferPending: false, - redeemableRevealCount: 0, - redeemableValueDoos: 0, - hasBidCommitment: canBidAllowed ? false : phase === "BIDDING", - hasBidCoin: false, - hasRevealCoin: false, - hasOwnerCoin: false, - revealTxid: null, - bidValueDoos: null, - canOpen: { allowed: false, reason: null }, - canBid: { - allowed: canBidAllowed, - reason: canBidAllowed ? null : phase === "OPENING" ? "Auction is opening" : "Already bid", - }, - canReveal: { allowed: false, reason: null }, - canRedeem: { allowed: false, reason: null }, - canRegister: { allowed: false, reason: null }, - canUpdate: { allowed: false, reason: null }, - canTransfer: { allowed: false, reason: null }, - canFinalize: { allowed: false, reason: null }, - canCancelTransfer: { allowed: false, reason: null }, - canRenew: { allowed: false, reason: null }, - canRevoke: { allowed: false, reason: null }, - nextActionKey: null, - nextActionLabel: null, - nextActionReason: null, - countdownLabel: null, - countdownBlocks: null, - countdownHours: null, - }); + return Promise.resolve( + makeCapabilities({ + name: "examplename", + phase, + // What the backend actually derives for these phases. + taskState: phase === "OPENING" ? "waitingForBidding" : "readyToBid", + hasBidCommitment: canBidAllowed ? false : phase === "BIDDING", + myBidCount: canBidAllowed || phase === "OPENING" ? 0 : 1, + canBid: { + allowed: canBidAllowed, + reason: canBidAllowed + ? null + : phase === "OPENING" + ? "Auction is opening" + : "Already bid", + }, + }), + ); } switch (cmd) { case "list_wallet_profiles": @@ -130,7 +113,12 @@ function wrapper() { beforeEach(() => invokeMock.mockReset()); -describe("NameActionsModal — BidGate hides inputs off the bidding phase", () => { +// `BidGate` is gone: it existed to decide whether the advanced section should +// draw the Bid/Lockup inputs, and `resolveSections` now decides whether that +// section exists at all. These tests keep the behaviour it was written for — +// no phase outside BIDDING may invite a bid — pinned on the modal itself, +// which is where a regression would actually show. +describe("NameActionsModal — no phase outside BIDDING invites a bid", () => { it("offers no advanced section at all during OPENING, so no bid can be invited", async () => { invokeMock.mockImplementation(route("OPENING", false)); render( {}} />, { @@ -145,7 +133,6 @@ describe("NameActionsModal — BidGate hides inputs off the bidding phase", () = expect(screen.queryByTestId("all-actions-toggle")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Bid (HNS)")).not.toBeInTheDocument(); expect(screen.queryByLabelText("Lockup (HNS)")).not.toBeInTheDocument(); - expect(screen.queryByTestId("bid-gate-placeholder")).not.toBeInTheDocument(); }); it("shows the Bid/Lockup inputs during BIDDING when canBid is allowed", async () => { @@ -158,7 +145,6 @@ describe("NameActionsModal — BidGate hides inputs off the bidding phase", () = await waitFor(() => { expect(screen.getAllByLabelText("Bid (HNS)").length).toBeGreaterThan(0); }); - expect(screen.queryByTestId("bid-gate-placeholder")).not.toBeInTheDocument(); }); it("hides the Show-all-actions toggle in BIDDING when every advanced action is disabled", async () => { diff --git a/src/components/__tests__/name-details.test.tsx b/src/components/__tests__/name-details.test.tsx index 9cdb97df..7958957e 100644 --- a/src/components/__tests__/name-details.test.tsx +++ b/src/components/__tests__/name-details.test.tsx @@ -2,19 +2,34 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { NameDetails } from "../name-actions/NameDetails"; -import { useNameRecords } from "../../queries/read"; -import { useNodeLive } from "../../queries/node"; -import type { HsdName } from "../../types"; +import { makeNodeStatus } from "../../test/fixtures/nodeStatus"; +import type { HsdName, NameResource } from "../../types"; // NameDetails is the read-only body extracted from the former NameInfoModal // when the two name modals were unified into NameActionsModal. These tests // preserve the read-only rendering coverage (heights, transfer, owner UTXO, // closed-auction values, DNS records) that used to live on NameInfoModal. -vi.mock("../../queries/read"); -vi.mock("../../queries/node"); -const mockUseNameRecords = vi.mocked(useNameRecords); -const mockUseNodeLive = vi.mocked(useNodeLive); +const invokeMock = vi.fn(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +/** Route the two commands the component reads: DNS records and node status. */ +function route(o: { records?: NameResource | null; nodeLive?: boolean } = {}) { + invokeMock.mockImplementation((cmd: string) => { + switch (cmd) { + case "read_name_records": + return Promise.resolve(o.records ?? { records: [] }); + case "node_status": + return Promise.resolve( + makeNodeStatus({ read_source: (o.nodeLive ?? true) ? "local" : "explorer" }), + ); + default: + return Promise.reject(new Error(`unexpected command ${cmd}`)); + } + }); +} function wrapper() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -37,17 +52,12 @@ function baseInfo(overrides: Partial = {}): HsdName { stats: null, transfer: 0, ...overrides, - } as HsdName; + }; } beforeEach(() => { - vi.clearAllMocks(); - mockUseNameRecords.mockReturnValue({ - data: { records: [] }, - isLoading: false, - isError: false, - } as any); - mockUseNodeLive.mockReturnValue(true); + invokeMock.mockReset(); + route(); }); describe("NameDetails", () => { @@ -103,33 +113,31 @@ describe("NameDetails", () => { expect(screen.getByText(/Transfer in progress/)).toBeInTheDocument(); }); - it("renders DNS records with TTL when node is live", () => { - mockUseNameRecords.mockReturnValue({ - data: { + it("renders DNS records with TTL when node is live", async () => { + route({ + records: { records: [ { type: "NS", ns: "ns1.example." }, { type: "TXT", txt: ["v=spf1 -all"] }, ], ttl: 3600, }, - isLoading: false, - isError: false, - } as any); + }); render(, { wrapper: wrapper(), }); + expect(await screen.findByText(/TTL:/)).toBeInTheDocument(); expect(screen.getByText("DNS Records")).toBeInTheDocument(); - expect(screen.getByText(/TTL:/)).toBeInTheDocument(); expect(screen.getByText("3600s")).toBeInTheDocument(); expect(screen.getByTestId("name-info-dns-table")).toBeInTheDocument(); }); - it("shows 'Requires a synced node' when node is not live", () => { - mockUseNodeLive.mockReturnValue(false); + it("shows 'Requires a synced node' when node is not live", async () => { + route({ nodeLive: false }); render(, { wrapper: wrapper(), }); - expect(screen.getByTestId("name-info-dns-no-node")).toBeInTheDocument(); + expect(await screen.findByTestId("name-info-dns-no-node")).toBeInTheDocument(); expect(screen.getByText(/Requires a synced local node/)).toBeInTheDocument(); }); diff --git a/src/components/__tests__/node-status.test.tsx b/src/components/__tests__/node-status.test.tsx index e6973442..0d5de6dd 100644 --- a/src/components/__tests__/node-status.test.tsx +++ b/src/components/__tests__/node-status.test.tsx @@ -40,6 +40,7 @@ type NodeOver = Partial<{ height: number | null; verification_progress: number | null; headers: number | null; + synced: boolean; last_error: string | null; index_mismatch: boolean; read_source: "local" | "explorer"; @@ -57,6 +58,11 @@ function nodeStatus(over: NodeOver = {}) { height: null, verification_progress: null, headers: null, + // The backend's own `chain_synced` verdict. The rule itself is tested in + // Rust (`chain_synced`); these tests pin what the UI renders when handed + // each answer, so each case states the verdict it is exercising rather + // than re-deriving one here. + synced: false, last_error: null, index_mismatch: false, read_source: "explorer", @@ -105,7 +111,7 @@ beforeEach(() => { describe("Node status (truthful, RPC-based)", () => { it("Settings shows Connected · block N when the RPC answers", async () => { invokeMock.mockImplementation( - route(nodeStatus({ connected: true, process_alive: true, height: 218456 })), + route(nodeStatus({ connected: true, process_alive: true, height: 218456, synced: true })), ); render(, { wrapper: wrapper() }); @@ -123,6 +129,7 @@ describe("Node status (truthful, RPC-based)", () => { height: 40000, headers: 100000, verification_progress: 0.4, + synced: false, }), ), ); @@ -146,6 +153,7 @@ describe("Node status (truthful, RPC-based)", () => { height: 65027, headers: 65027, verification_progress: 0.19, + synced: false, }), ), ); @@ -172,6 +180,7 @@ describe("Node status (truthful, RPC-based)", () => { height: 317, headers: 317, verification_progress: 0.9997, + synced: true, }), ), ); @@ -193,6 +202,7 @@ describe("Node status (truthful, RPC-based)", () => { height: 317, headers: 317, verification_progress: 0.08, + synced: false, }), ), ); @@ -211,6 +221,7 @@ describe("Node status (truthful, RPC-based)", () => { height: 317, headers: 317, verification_progress: 0.9999, + synced: true, }), ), ); diff --git a/src/components/name-actions/ActionHint.tsx b/src/components/name-actions/ActionHint.tsx index 9b1139cf..d0fa8b10 100644 --- a/src/components/name-actions/ActionHint.tsx +++ b/src/components/name-actions/ActionHint.tsx @@ -14,8 +14,13 @@ type Props = { * `Button` carries `disabled:pointer-events-none`, so a disabled button * receives no pointer events and a native `title` on it is never shown — the * tooltip went dead exactly when it had something to say. [`Tooltip`] listens - * on a wrapper the pointer can still reach, and renders nothing extra when - * `reason` is empty. + * on a wrapper the pointer can still reach, and shows nothing when `reason` is + * empty — the wrapper itself is always rendered, for the reason its own code + * gives. + * + * It is a thin wrapper on purpose. What it adds is the name: at a call site + * `reason` says this is a capability's refusal, where `content` would say only + * that some text exists. */ export function ActionHint({ reason, children }: Props) { return {children}; diff --git a/src/components/name-actions/BidGate.tsx b/src/components/name-actions/BidGate.tsx deleted file mode 100644 index 84bac28b..00000000 --- a/src/components/name-actions/BidGate.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { ReactNode } from "react"; -import { BidForm, type BidFormProps } from "./BidForm"; -import { formatCountdown, type PhaseBadge, type PhaseCountdown } from "../../lib/auction"; -import type { NameActionCapability } from "../../types"; - -/** - * The single gate for the bid + lockup inputs (Diagnostic fix: the advanced - * auction section used to render {@link BidForm} unconditionally, so the Bid / - * Lockup fields showed — inviting input — in phases where bidding is - * impossible, e.g. right next to "Open" during OPENING. That made it look as - * though Open would also place a bid. - * - * The gate is a single predicate — `canBid.allowed` — shared by BOTH call - * sites (the guided BIDDING panel and the advanced section): - * - * - `canBid.allowed` true → render the {@link BidForm} (the "dumb" input pair). - * - false + a pre-bidding phase (AVAILABLE / OPENING / a BIDDING phase where - * the user has already bid) → render a contextual placeholder that says - * how long to wait (reusing the existing `countdown` / {@link formatCountdown}) - * and what to do next, falling back to `canBid.reason`. - * - false + any other phase (REVEAL / CLOSED / TRANSFER / REVOKED) → render - * nothing; the advanced section already surfaces those actions elsewhere. - * - * `BidForm` stays "dumb": it only knows how to draw the fields. All phase - * reasoning lives here. - */ -export interface BidGateProps extends BidFormProps { - /** The single visibility predicate: whether bidding is possible right now. */ - canBid: NameActionCapability; - /** Current auction phase — used to pick the placeholder copy. */ - phase: PhaseBadge["phase"]; - /** Existing countdown for this name; drives "opens in …" / "Reveal opens in …". */ - countdown: PhaseCountdown | null; -} - -/** The contextual placeholder shown when the inputs are gated off. Returns - * null for phases where a bid-related hint would be noise. */ -function bidPlaceholder( - phase: PhaseBadge["phase"], - countdown: PhaseCountdown | null, - reason: string | null, -): ReactNode { - const cd = countdown ? formatCountdown(countdown) : null; - - let body: ReactNode = null; - switch (phase) { - case "AVAILABLE": - body = "Open the auction first; bidding starts after the opening period."; - break; - case "OPENING": - body = cd - ? `Bidding opens in ${cd}. You'll set your bid & lockup then.` - : "Bidding opens after the opening period. You'll set your bid & lockup then."; - break; - case "BIDDING": - // canBid is false in BIDDING only once the user has already placed a bid. - body = cd ? `Your bid is placed. Reveal opens in ${cd}.` : "Your bid is placed."; - break; - default: - // REVEAL / CLOSED / TRANSFER / REVOKED / OTHER — no bid hint here. - return null; - } - - return ( -
- {body} - {/* Fall back to the backend-provided reason when our copy is generic. */} - {reason && !cd &&
{reason}
} -
- ); -} - -export function BidGate({ canBid, phase, countdown, ...formProps }: BidGateProps) { - if (canBid.allowed) { - return ; - } - return <>{bidPlaceholder(phase, countdown, canBid.reason)}; -} diff --git a/src/components/name-actions/GuidedAction.tsx b/src/components/name-actions/GuidedAction.tsx index 53c1e062..a79b4571 100644 --- a/src/components/name-actions/GuidedAction.tsx +++ b/src/components/name-actions/GuidedAction.tsx @@ -180,30 +180,14 @@ export function GuidedAction({ ); case "BIDDING": - // Task-state precedence over raw on-chain phase: once THIS wallet has - // placed its bid the backend reports `waitingForBidding` (the badge - // reads "Waiting for Bidding"). The name is still in the on-chain - // BIDDING phase, but there is no bid action left for this wallet — one - // bid per wallet per name — so render a wait-for-reveal panel instead - // of the bid form. The switch keyed purely on `badge.phase`, so an - // already-bid name showed the "Place a Bid" form under a - // "Waiting for Bidding" badge. - if (caps?.taskState === "waitingForBidding") { - return ( -
- You already placed a bid for this name (one bid per wallet per name). Wait for the - reveal window, then reveal your bid. -
- {countdown - ? `Your bid is placed. Reveal opens in ${formatCountdown(countdown)}.` - : // Verbatim match with the backend `next_action_reason` for this - // already-bid WaitingForBidding case (see next_action override - // in names.rs). Keep the two strings identical. - "Your bid is placed. Wait for the reveal window to open."} -
-
- ); - } + // A wallet that has already bid is still invited to bid again: several + // independent bids on one name are allowed for the rest of the window. + // This case used to collapse to a wait-for-reveal panel once a bid was + // placed, keyed on the backend reporting `waitingForBidding` during + // BIDDING — which it no longer does, and the panel's copy still named + // the one-bid-per-wallet rule that was removed with it. How many bids + // are yours is said by the modal header and the tinted rows in the bid + // list instead. return (
Ownership
diff --git a/src/components/name-actions/__tests__/bid-gate.test.tsx b/src/components/name-actions/__tests__/bid-gate.test.tsx deleted file mode 100644 index 56171fd9..00000000 --- a/src/components/name-actions/__tests__/bid-gate.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import { BidGate } from "../BidGate"; -import type { PhaseCountdown } from "../../../lib/auction"; -import type { NameActionCapability } from "../../../types"; - -/** - * Regression: the advanced auction section used to render - * unconditionally, so Bid / Lockup inputs were visible in phases where - * bidding is impossible (AVAILABLE / OPENING / BIDDING-after-bid). The - * shared BidGate replaces that: a single `canBid.allowed` predicate decides - * whether the fields appear, with a contextual placeholder (using the - * existing countdown) when they do not. - */ - -const baseFormProps = { - variant: "advanced" as const, - bidHns: "", - onBidChange: vi.fn(), - lockupHns: "", - onLockupChange: vi.fn(), - bidError: null, - lockupError: null, - forfeitLockupText: "0", - disabled: false, - busy: false, - onSubmit: vi.fn(), - idleLabel: "Bid", - busyLabel: "…", -}; - -const allowed: NameActionCapability = { allowed: true, reason: null }; -const denied = (reason: string | null = null): NameActionCapability => ({ - allowed: false, - reason, -}); - -const openingCountdown: PhaseCountdown = { - label: "Bidding opens in", - blocks: 12, - hours: 2, -}; -const revealCountdown: PhaseCountdown = { - label: "Reveal starts in", - blocks: 6, - hours: 1, -}; - -describe("BidGate", () => { - it("renders the BidForm fields when canBid.allowed is true", () => { - render( - , - ); - // BidForm inputs are labelled by /"Lockup (HNS)". - expect(screen.getByLabelText("Bid (HNS)")).toBeInTheDocument(); - expect(screen.getByLabelText("Lockup (HNS)")).toBeInTheDocument(); - expect(screen.queryByTestId("bid-gate-placeholder")).not.toBeInTheDocument(); - }); - - it("hides fields and shows the AVAILABLE placeholder when canBid is denied", () => { - render( - , - ); - expect(screen.queryByLabelText("Bid (HNS)")).not.toBeInTheDocument(); - const ph = screen.getByTestId("bid-gate-placeholder"); - expect(ph).toHaveTextContent(/open the auction first/i); - }); - - it("shows the OPENING placeholder with the formatted countdown", () => { - render( - , - ); - expect(screen.queryByLabelText("Bid (HNS)")).not.toBeInTheDocument(); - // formatCountdown(openingCountdown) → "12 blocks (~2h)" - expect(screen.getByTestId("bid-gate-placeholder")).toHaveTextContent( - /Bidding opens in 12 blocks \(~2h\)/i, - ); - }); - - it("shows the hasBid placeholder in BIDDING when canBid is denied", () => { - // canBid.allowed is false in BIDDING only once the user has already bid. - render( - , - ); - expect(screen.queryByLabelText("Bid (HNS)")).not.toBeInTheDocument(); - expect(screen.getByTestId("bid-gate-placeholder")).toHaveTextContent( - /Your bid is placed\. Reveal opens in 6 blocks \(~1h\)/i, - ); - }); - - it("falls back to canBid.reason when no countdown is available", () => { - render( - , - ); - const ph = screen.getByTestId("bid-gate-placeholder"); - expect(ph).toHaveTextContent(/Bidding opens after the opening period/i); - // Fallback reason is surfaced beneath the generic copy. - expect(ph).toHaveTextContent(/Node not synced/i); - }); - - it("renders nothing outside pre-bidding phases (REVEAL / CLOSED / TRANSFER / REVOKED)", () => { - for (const phase of ["REVEAL", "CLOSED", "TRANSFER", "REVOKED"] as const) { - const { container, unmount } = render( - , - ); - expect(container.textContent ?? "").toBe(""); - expect(screen.queryByLabelText("Bid (HNS)")).not.toBeInTheDocument(); - unmount(); - } - }); -}); diff --git a/src/components/name-actions/__tests__/name-sign-message.test.tsx b/src/components/name-actions/__tests__/name-sign-message.test.tsx index bf5cb9d4..a81b2586 100644 --- a/src/components/name-actions/__tests__/name-sign-message.test.tsx +++ b/src/components/name-actions/__tests__/name-sign-message.test.tsx @@ -65,6 +65,11 @@ function capsFor(name: string, ownsName: boolean): NameActionCapabilities { countdownLabel: null, countdownBlocks: null, countdownHours: null, + auctionBiddingBlocks: null, + auctionRevealBlocks: null, + pendingBroadcastAction: null, + strandedBidCount: 0, + strandedLockupDoos: 0, }; } diff --git a/src/components/ui/AllowRemoteBroadcastToggle.tsx b/src/components/ui/AllowRemoteBroadcastToggle.tsx new file mode 100644 index 00000000..2481f0e0 --- /dev/null +++ b/src/components/ui/AllowRemoteBroadcastToggle.tsx @@ -0,0 +1,50 @@ +export interface AllowRemoteBroadcastToggleProps { + /** Whether sending through a remote node is currently allowed. */ + checked: boolean; + /** Called with the new value. */ + onChange: (checked: boolean) => void; + /** + * Render the explanatory line under the label. Settings shows it; the + * onboarding step is already a page of explanation, so it does not. + */ + showDescription?: boolean; + /** Label size, matching the surrounding text. */ + size?: "sm" | "xs"; +} + +/** + * The single "Allow sending via remote node" control, shared by the onboarding + * Remote step and Settings. + * + * Both screens hand-rolled the same checkbox with the same label and the same + * `data-testid`, which is two places for the wording of a safety opt-in to + * drift apart and one testid that would match twice if a page ever rendered + * both. What the toggle writes is the `allow_remote_broadcast` setting the + * backend enforces in `broadcast_tx_draft` — the UI gate is not the guard, so + * the two screens must at least agree on what they are asking. + */ +export function AllowRemoteBroadcastToggle({ + checked, + onChange, + showDescription = false, + size = "sm", +}: AllowRemoteBroadcastToggleProps) { + return ( + + ); +} diff --git a/src/components/ui/NetworkSelect.tsx b/src/components/ui/NetworkSelect.tsx new file mode 100644 index 00000000..7fb3255f --- /dev/null +++ b/src/components/ui/NetworkSelect.tsx @@ -0,0 +1,39 @@ +import type { WalletNetwork } from "../../types"; + +export interface NetworkSelectProps { + /** The currently chosen network. */ + value: WalletNetwork; + /** Called with the new network. */ + onChange: (network: WalletNetwork) => void; + /** testid for the select. */ + testId?: string; + /** Extra classes on the select, for a caller that constrains its width. */ + className?: string; +} + +/** + * The network picker, shared by first-run onboarding and Add wallet. + * + * A profile's network is immutable once created, so this is the last moment + * the choice can be made and the one place the three options are listed. The + * two screens each had their own copy and had already drifted: only one + * carried a `data-testid`, so only one was reachable from a test. + * + * Which networks exist is a fact about the wallet, not about either screen. + */ +export function NetworkSelect({ value, onChange, testId, className }: NetworkSelectProps) { + return ( + + ); +} diff --git a/src/components/ui/NotificationToggle.tsx b/src/components/ui/NotificationToggle.tsx new file mode 100644 index 00000000..84999fe9 --- /dev/null +++ b/src/components/ui/NotificationToggle.tsx @@ -0,0 +1,110 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { + checkNotificationPermission, + requestNotificationPermission, + type PermissionStatus, +} from "../../lib/notifications"; +import { boolToSetting, settingToBool } from "../../lib/settingsBool"; +import type { Settings } from "../../types"; + +export interface NotificationToggleProps { + /** The `Settings` key this toggle writes ("true" / "false"). */ + settingKey: keyof Settings; + /** Checkbox label. */ + label: string; + /** testid for the checkbox. */ + testId: string; + /** testid for the "OS notifications are blocked" notice. */ + deniedTestId: string; + /** + * What the user loses while OS notifications are blocked. Each kind of alert + * loses something different, so the sentence is the caller's; the framing + * around it is not. + */ + deniedConsequence: ReactNode; + /** The settings form and its updater, as the Settings screen holds them. */ + form: Record; + updateField: (key: string, value: string) => void; + /** Extra fields shown only while the toggle is on — lead times and the like. */ + children?: ReactNode; +} + +/** + * One notification opt-in: the checkbox, the OS permission request it triggers, + * and the three notices that follow from the answer. + * + * The permission request has to originate from the user's click — macOS + * silently denies one that does not — so it lives here beside the checkbox + * rather than in the form's Save. The enabled flag itself is an ordinary form + * field, saved with every other setting. + * + * Three sections had this written out in full, identical but for the setting + * key, the label and one sentence. That is three places to fix a permission + * bug and three chances for the notices to drift apart. + */ +export function NotificationToggle({ + settingKey, + label, + testId, + deniedTestId, + deniedConsequence, + form, + updateField, + children, +}: NotificationToggleProps) { + const [permission, setPermission] = useState(null); + const [requesting, setRequesting] = useState(false); + const enabled = settingToBool(form[settingKey]); + + useEffect(() => { + checkNotificationPermission().then(setPermission); + }, []); + + const onToggle = async (checked: boolean) => { + updateField(settingKey, boolToSetting(checked)); + // Turning it off needs no permission, and asking then would be a prompt + // the user did not invite. + if (!checked) return; + setRequesting(true); + try { + setPermission(await requestNotificationPermission()); + } finally { + setRequesting(false); + } + }; + + return ( +
+ + + {enabled && ( + <> + {permission === "denied" && ( +
+ OS notifications are blocked for this app. Enable them in your system notification + settings — {deniedConsequence} +
+ )} + {permission === "unsupported" && ( +
+ OS notifications aren't available outside the desktop app. +
+ )} + {requesting &&
Requesting permission…
} + {children} + + )} +
+ ); +} diff --git a/src/components/ui/RemoteNodeFields.tsx b/src/components/ui/RemoteNodeFields.tsx index e5136b1e..276f2281 100644 --- a/src/components/ui/RemoteNodeFields.tsx +++ b/src/components/ui/RemoteNodeFields.tsx @@ -2,6 +2,7 @@ import { Input } from "./Input"; import { Button } from "./Button"; import { ConnectionCheckStatus } from "./ConnectionCheckStatus"; import type { NodeConnectionCheckState } from "../../hooks/useNodeConnectionCheck"; +import type { WalletNetwork } from "../../types"; export interface RemoteNodeFieldsProps { /** RPC URL value. */ @@ -22,7 +23,7 @@ export interface RemoteNodeFieldsProps { * Threaded straight into `probe.run` — Settings, which has an active * profile, may omit it and let the backend fall back to the stored network. */ - expectedNetwork?: string; + expectedNetwork?: WalletNetwork; /** Label above the URL input. Omit for a placeholder-only field. */ urlLabel?: string; /** Label above the API key input. Omit for a placeholder-only field. */ diff --git a/src/components/ui/Tooltip.tsx b/src/components/ui/Tooltip.tsx index 776d78db..c9a1590c 100644 --- a/src/components/ui/Tooltip.tsx +++ b/src/components/ui/Tooltip.tsx @@ -30,8 +30,9 @@ export const TOOLTIP_OPEN_DELAY_MS = 300; interface TooltipProps { /** * Tooltip body. Kept short — one or two lines. When empty (`null`, - * `undefined` or `""`) the children render bare, with no wrapper and no - * hover handling, so callers can pass a conditional reason directly. + * `undefined` or `""`) no tooltip is shown and no hover handling applies, + * so callers can pass a conditional reason directly. The trigger wrapper is + * still rendered — see the note on `hasContent` for why it must be. */ content: ReactNode; /** The trigger. Rendered inline; gets the hover/focus reference props. */ diff --git a/src/hooks/useNodeConnectionCheck.ts b/src/hooks/useNodeConnectionCheck.ts index dafe6c40..109355af 100644 --- a/src/hooks/useNodeConnectionCheck.ts +++ b/src/hooks/useNodeConnectionCheck.ts @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { invoke } from "../lib/invoke"; -import type { NodeConnectionCheck } from "../types"; +import type { NodeConnectionCheck, WalletNetwork } from "../types"; export interface NodeConnectionCheckState { /** A probe is in flight. */ @@ -19,7 +19,7 @@ export interface NodeConnectionCheckState { * network — which during onboarding is `None`, making every probe pass * regardless of the node's actual chain. */ - run: (url: string, apiKey?: string, expectedNetwork?: string) => Promise; + run: (url: string, apiKey?: string, expectedNetwork?: WalletNetwork) => Promise; /** Forget the last outcome — call whenever the URL or key being probed changes. */ reset: () => void; } @@ -53,7 +53,7 @@ export function useNodeConnectionCheck(): NodeConnectionCheckState { setTesting(false); }; - const run = async (url: string, apiKey?: string, expectedNetwork?: string) => { + const run = async (url: string, apiKey?: string, expectedNetwork?: WalletNetwork) => { requestId.current += 1; const myRequestId = requestId.current; const trimmed = url.trim(); diff --git a/src/lib/auction.test.ts b/src/lib/auction.test.ts index 4b820800..165528c8 100644 --- a/src/lib/auction.test.ts +++ b/src/lib/auction.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { NameActionCapabilities } from "../types"; +import { makeCapabilities } from "../test/fixtures/capabilities"; import { auctionPhase, nextTransition, @@ -157,26 +157,20 @@ describe("taskSummaryFromCapabilities — redeeming on a name you own", () => { // the name: the losing reveals to reclaim are your own. Labelling that // "Lost — Redeem Now" on a name the wallet just registered is false, and it // is the ordinary outcome of the multi-bid flow, not a corner. - const base = { + const base = makeCapabilities({ phase: "CLOSED", taskState: "lostNeedsRedeem", hasBidCommitment: false, - } as unknown as NameActionCapabilities; + }); it("does not call it a loss when the wallet owns the name", () => { - const s = taskSummaryFromCapabilities({ - ...base, - ownsName: true, - } as NameActionCapabilities); + const s = taskSummaryFromCapabilities({ ...base, ownsName: true }); expect(s?.label).not.toMatch(/lost/i); expect(s?.label).toMatch(/reclaim/i); }); it("still calls a genuine loss a loss", () => { - const s = taskSummaryFromCapabilities({ - ...base, - ownsName: false, - } as NameActionCapabilities); + const s = taskSummaryFromCapabilities({ ...base, ownsName: false }); expect(s?.label).toMatch(/lost/i); }); }); diff --git a/src/lib/nameSections.test.ts b/src/lib/nameSections.test.ts index b13d3035..17880b82 100644 --- a/src/lib/nameSections.test.ts +++ b/src/lib/nameSections.test.ts @@ -1,50 +1,10 @@ import { describe, it, expect } from "vitest"; import { resolveSections } from "./nameSections"; -import type { NameActionCapabilities, NameActionCapability } from "../types"; - -const no = (reason: string): NameActionCapability => ({ allowed: false, reason }); -const yes: NameActionCapability = { allowed: true, reason: null }; - -/** A capabilities object with everything refused; each test says what it allows. */ -function caps(over: Partial = {}): NameActionCapabilities { - const denied = no("not now"); - return { - name: "example", - phase: "CLOSED", - taskState: "unavailableOther", - ownsName: false, - transferPending: false, - redeemableRevealCount: 0, - redeemableValueDoos: 0, - nameIsRegistered: false, - hasBidCommitment: false, - hasBidCoin: false, - hasRevealCoin: false, - hasOwnerCoin: false, - revealTxid: null, - bidValueDoos: null, - lockupValueDoos: null, - myBidCount: 0, - canOpen: denied, - canBid: denied, - canReveal: denied, - canRedeem: denied, - canRegister: denied, - canUpdate: denied, - canTransfer: denied, - canFinalize: denied, - canCancelTransfer: denied, - canRenew: denied, - canRevoke: denied, - nextActionKey: null, - nextActionLabel: null, - nextActionReason: null, - countdownLabel: null, - countdownBlocks: null, - countdownHours: null, - ...over, - }; -} +import { + allowed as yes, + makeCapabilities as caps, + refused as no, +} from "../test/fixtures/capabilities"; describe("resolveSections — leading your own auction is not owning the name", () => { // The case the whole three-state split exists for. During REVEAL hsd reports diff --git a/src/lib/openExternal.ts b/src/lib/openExternal.ts index 458c0340..f88a1e6a 100644 --- a/src/lib/openExternal.ts +++ b/src/lib/openExternal.ts @@ -1,4 +1,5 @@ import { isBrowser } from "./runtime"; +import type { WalletNetwork } from "../types"; /** * The Handshake block explorer used for all human-facing links. @@ -45,6 +46,14 @@ export function resolveReleaseNotesHref(href: string, ref = "HEAD"): string { */ export const EXPLORER_TX_BASE = `${SHAKESHIFT_BASE}/transaction`; +/** + * Whether the explorer has pages for this chain at all. Shakeshift indexes + * mainnet only, so a name / tx / address link for any other network would + * 404 — every screen that offers such a link asks this one question. + */ +export const explorerCoversNetwork = (network: WalletNetwork | null | undefined): boolean => + network === "mainnet"; + /** Build the explorer URL for a transaction id. */ export function explorerTxUrl(txid: string): string { return `${SHAKESHIFT_BASE}/transaction/${txid}`; diff --git a/src/lib/settingsDefaults.ts b/src/lib/settingsDefaults.ts new file mode 100644 index 00000000..c11b1fb5 --- /dev/null +++ b/src/lib/settingsDefaults.ts @@ -0,0 +1,43 @@ +/** + * The default value of every setting, in one typed object. + * + * It lives in a leaf module on purpose. The settings store imports `invoke`, + * and `invoke` imports the browser-QA mock, so a mock that reached back into + * the store for these defaults would close an import cycle and leave the + * object undefined at module-init time, depending on which side loaded first. + * Everything that needs the defaults imports them from here instead. + * + * Typed as `Settings`, so adding a setting is a type error until its default + * is written down — which is what keeps the mock and the real backend + * answering with the same shape. + */ +import type { Settings } from "../types"; + +export const DEFAULT_SETTINGS: Settings = { + // Sending node (hsd RPC); reads come from the explorer below. + node_rpc_url: "http://127.0.0.1:12037", + node_rpc_api_key: "", + hsd_prefix: "", + hsd_path: "", + autostart_hsd: "true", + explorer_api_url: "https://e.hnsfans.com", + address_gap_limit: "20", + signer_session_timeout_seconds: "900", + onboarding_complete: "false", + deadline_notify_enabled: "false", + deadline_notify_reveal_lead_blocks: "144", + deadline_notify_renewal_lead_days: "30", + watchlist_notify_enabled: "false", + watchlist_notify_bidding_soon_lead_blocks: "144", + watchlist_notify_highest_bid_threshold_hns: "", + background_sync_enabled: "1", + node_mode: "full", + explorer_fallback_url: "", + chain_source: "local_node", + close_to_tray: "1", + allow_remote_broadcast: "false", + tray_hint_shown: "0", + launch_at_login: "0", + fee_rate_doos_per_kvb: "", + update_notify_enabled: "false", +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 9e3d6cbf..afe36135 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -62,8 +62,13 @@ export function hnsAddressPrefix(network: string): string { /** * The loopback node RPC URL hsd listens on for a given network (its - * `networks.js` `rpcPort`). Mirrors `Network::default_rpc_url` in - * `src-tauri/src/noncustodial/network.rs`; keep the two in step. + * `networks.js` `rpcPort`). + * + * The backend has the same table. Duplicating it is deliberate: the only use + * here is a placeholder in Settings, and a round-trip for grey hint text would + * cost more than the bug it prevents. A Rust test reads this function and + * fails if the two disagree, so the agreement is checked rather than + * remembered — see `contract_shape_tests`. */ export function defaultNodeRpcUrl(network: string): string { switch (network) { diff --git a/src/lib/webqa-mock.ts b/src/lib/webqa-mock.ts index a72e9f23..2b9109ad 100644 --- a/src/lib/webqa-mock.ts +++ b/src/lib/webqa-mock.ts @@ -10,6 +10,9 @@ */ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { DEFAULT_SETTINGS } from "./settingsDefaults"; +import type { NodeConnectionCheck, Settings } from "../types"; + type Handler = (args?: Record) => unknown; // ═══════════════════════════════════════════════════════════════════════════ @@ -344,23 +347,32 @@ function auctionPositionNames(): string[] { const handlers: Record = { // ── Settings ────────────────────────────────────────────────────────── - get_settings: () => ({ - node_rpc_url: "http://127.0.0.1:12037", - node_rpc_api_key: "", - hsd_prefix: "", - hsd_path: "", - explorer_api_url: "https://e.hnsfans.com", - address_gap_limit: "20", - signer_session_timeout_seconds: "900", + // Built from the typed defaults rather than hand-listed: this map used to + // omit settings the real backend always returns (`allow_remote_broadcast` + // among them), so a screen that reads one saw `undefined` in browser QA and + // nowhere else. Spreading the typed object means a new setting cannot be + // forgotten here. + get_settings: (): Settings => ({ + ...DEFAULT_SETTINGS, onboarding_complete: "true", - background_sync_enabled: "1", - node_mode: "full", - explorer_fallback_url: "", - chain_source: "local_node", }), update_setting: () => null, + // Browser QA has no node to probe. Answer as a reachable, synced, matching + // node so "Test connection" completes instead of falling through to the + // unknown-command warning and returning null, which the caller reads as a + // failure it cannot explain. + check_node_connection: (): NodeConnectionCheck => ({ + reachable: true, + height: 100_000, + headers: 100_000, + synced: true, + network: "regtest", + networkMatches: true, + error: null, + }), + // ── Daemon control ──────────────────────────────────────────────────── is_background_sync_enabled: () => true, set_background_sync_enabled: () => null, diff --git a/src/queries/node.ts b/src/queries/node.ts index 545cf254..e94c652a 100644 --- a/src/queries/node.ts +++ b/src/queries/node.ts @@ -19,6 +19,13 @@ export interface NodeStatus { verification_progress: number | null; /** Peers' best header height (the sync target), when reported. */ headers: number | null; + /** + * Whether the node has reached the chain tip, as the backend's own + * `chain_synced` rule decides it — the same verdict the read and write gates + * act on. Read this rather than comparing height/headers/progress here, or + * the label and what reads actually do can disagree. + */ + synced: boolean; /** Why the last start failed (with log tail), when the RPC isn't answering. */ last_error: string | null; /** The failure is a chain/index mismatch hsd can't fix in place → offer re-sync. */ diff --git a/src/queries/read.ts b/src/queries/read.ts index a55ae346..33aad154 100644 --- a/src/queries/read.ts +++ b/src/queries/read.ts @@ -172,13 +172,28 @@ export function useReadNameInfo(name: string | null | undefined): UseQueryResult * Fetches `get_name_action_capabilities` to evaluate what actions are * available right now for the active wallet. */ +/** + * The cache key [`useNameActionCapabilities`] stores under. + * + * Exported because the auctions table seeds this cache from its own batch + * result, so that opening a name's modal shows the badge the clicked row + * already showed instead of flashing the raw on-chain phase. That seeding + * used to spell the key out again under a comment asking for the two to be + * kept identical — and a key that is merely meant to match is a key that can + * stop matching, silently, with the only symptom a flash nobody reports. + */ +export const nameCapabilitiesQueryKey = ( + profileId: string | null, + name: string | null | undefined, +) => ["read", "nameCapabilities", profileId, name ?? ""] as const; + export function useNameActionCapabilities( name: string | null | undefined, walletProfileId?: string | null, ): UseQueryResult { const profileId = walletProfileId ?? null; return useQuery({ - queryKey: ["read", "nameCapabilities", profileId, name ?? ""], + queryKey: nameCapabilitiesQueryKey(profileId, name), enabled: Boolean(name && name.trim().length > 0), queryFn: async () => { // Pin the evaluation to THIS wallet so capabilities can never reflect diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 1f5def2e..4d37e5d4 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { invoke } from "../lib/invoke"; +import { DEFAULT_SETTINGS } from "../lib/settingsDefaults"; import type { Settings } from "../types"; interface SettingsState { @@ -10,34 +11,7 @@ interface SettingsState { saveAll: (partial: Partial) => Promise; } -export const DEFAULT_SETTINGS: Settings = { - // Sending node (hsd RPC); reads come from the explorer below. - node_rpc_url: "http://127.0.0.1:12037", - node_rpc_api_key: "", - hsd_prefix: "", - hsd_path: "", - autostart_hsd: "true", - explorer_api_url: "https://e.hnsfans.com", - address_gap_limit: "20", - signer_session_timeout_seconds: "900", - onboarding_complete: "false", - deadline_notify_enabled: "false", - deadline_notify_reveal_lead_blocks: "144", - deadline_notify_renewal_lead_days: "30", - watchlist_notify_enabled: "false", - watchlist_notify_bidding_soon_lead_blocks: "144", - watchlist_notify_highest_bid_threshold_hns: "", - background_sync_enabled: "1", - node_mode: "full", - explorer_fallback_url: "", - chain_source: "local_node", - close_to_tray: "1", - allow_remote_broadcast: "false", - tray_hint_shown: "0", - launch_at_login: "0", - fee_rate_doos_per_kvb: "", - update_notify_enabled: "false", -}; +export { DEFAULT_SETTINGS } from "../lib/settingsDefaults"; export const useSettingsStore = create((set, get) => ({ settings: null, diff --git a/src/test/fixtures/capabilities.ts b/src/test/fixtures/capabilities.ts new file mode 100644 index 00000000..a18e31c0 --- /dev/null +++ b/src/test/fixtures/capabilities.ts @@ -0,0 +1,71 @@ +import type { NameActionCapabilities, NameActionCapability } from "../../types"; + +/** A refused capability, carrying the reason a test wants to assert on. */ +export const refused = (reason: string | null = null): NameActionCapability => ({ + allowed: false, + reason, +}); + +/** An allowed capability. */ +export const allowed: NameActionCapability = { allowed: true, reason: null }; + +/** + * A complete `NameActionCapabilities` with every action refused; each test + * overrides only what it is about. + * + * Complete on purpose. Tests that built this object by hand either listed + * every field — and then had to be found and edited whenever the backend grew + * one — or reached for `as unknown as NameActionCapabilities`, which switches + * the type check off entirely and lets a test assert against a shape the + * backend never sends. Adding a field to the type is now a type error here and + * nowhere else. + * + * The defaults are the conservative answer the backend itself falls back to: + * nothing owned, nothing registered, nothing allowed. + */ +export function makeCapabilities( + over: Partial = {}, +): NameActionCapabilities { + const denied = refused(); + return { + name: "example", + phase: "CLOSED", + taskState: "unavailableOther", + ownsName: false, + nameIsRegistered: false, + transferPending: false, + hasBidCommitment: false, + hasBidCoin: false, + hasRevealCoin: false, + hasOwnerCoin: false, + revealTxid: null, + bidValueDoos: null, + lockupValueDoos: null, + myBidCount: 0, + canOpen: denied, + canBid: denied, + canReveal: denied, + canRedeem: denied, + canRegister: denied, + canUpdate: denied, + canTransfer: denied, + canFinalize: denied, + canCancelTransfer: denied, + canRenew: denied, + canRevoke: denied, + nextActionKey: null, + nextActionLabel: null, + nextActionReason: null, + countdownLabel: null, + countdownBlocks: null, + countdownHours: null, + auctionBiddingBlocks: null, + auctionRevealBlocks: null, + pendingBroadcastAction: null, + strandedBidCount: 0, + strandedLockupDoos: 0, + redeemableRevealCount: 0, + redeemableValueDoos: 0, + ...over, + }; +} diff --git a/src/test/fixtures/nodeStatus.ts b/src/test/fixtures/nodeStatus.ts new file mode 100644 index 00000000..2afe989d --- /dev/null +++ b/src/test/fixtures/nodeStatus.ts @@ -0,0 +1,27 @@ +import type { NodeStatus } from "../../queries/node"; + +/** + * A complete `NodeStatus`: no node running, reads from the explorer. Tests + * override the verdict they are about (`synced`, `read_source`) rather than + * re-deriving one — the rule itself is pinned in Rust (`chain_synced`). + */ +export function makeNodeStatus(over: Partial = {}): NodeStatus { + return { + binary: "/usr/local/bin/hsd", + binary_found: true, + version: "hsd 8.0.0", + data_dir: "/Volumes/WD/hsd-data", + network: "main", + process_alive: false, + connected: false, + height: null, + verification_progress: null, + headers: null, + synced: false, + last_error: null, + index_mismatch: false, + read_source: "explorer", + node_mode: "full", + ...over, + }; +} diff --git a/src/test/fixtures/wallet.ts b/src/test/fixtures/wallet.ts new file mode 100644 index 00000000..961c8cac --- /dev/null +++ b/src/test/fixtures/wallet.ts @@ -0,0 +1,37 @@ +import type { SignerSessionSummary, WalletProfileSummary } from "../../types"; + +/** + * A complete `WalletProfileSummary`; each test overrides only what it is + * about. Complete on purpose (see `capabilities.ts`): adding a field to the + * type is a type error here and nowhere else. + */ +export function makeProfile(over: Partial = {}): WalletProfileSummary { + return { + id: "p1", + label: "Primary", + kind: "mnemonic_hot", + network: "mainnet", + accountXpub: "xpubFAKE", + accountIndex: 0, + receiveDepth: 1, + changeDepth: 0, + receiveAddress: "hs1qfake", + lastSyncedHeight: null, + lastSyncedAt: null, + lastExplorerSyncAt: null, + watchOnly: false, + hasPassphrase: true, + active: true, + ...over, + }; +} + +/** A complete `SignerSessionSummary`, locked unless told otherwise. */ +export function makeSession(over: Partial = {}): SignerSessionSummary { + return { + walletProfileId: "p1", + unlocked: false, + unlockedUntilEpochMs: 0, + ...over, + }; +} diff --git a/src/types/index.ts b/src/types/index.ts index 0d8ca070..bea56483 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -393,7 +393,7 @@ export interface Settings { * for updates every ~4 hours and fires an OS notification if one is found, * deduped against `last_notified_update_version`. */ - update_notify_enabled?: string; + update_notify_enabled: string; } // --------------------------------------------------------------------------- @@ -664,25 +664,24 @@ export interface NameActionCapabilities { /** * How long this network's auction phases run, in blocks. Static per network, * but needed before an auction exists — to say what opening one commits to. - * Optional so existing fixtures stay valid; `null` when the backend could not - * determine the network. + * `null` when the backend could not determine the network. */ - auctionBiddingBlocks?: number | null; - auctionRevealBlocks?: number | null; + auctionBiddingBlocks: number | null; + auctionRevealBlocks: number | null; /** * The action this wallet has broadcast for the name that is still waiting * for a block (`"open"`, `"reveal"`, …). Until it is mined the chain reports * the name's previous state, so nothing phase-derived can describe it. */ - pendingBroadcastAction?: string | null; + pendingBroadcastAction: string | null; /** * Bids placed in an EARLIER auction of the same name whose lockup is * stranded — the auction closed without a reveal, so it can be neither * revealed nor redeemed. Scoping the bids panel by auction hid these, which * leaves money missing with nothing on screen to explain it. */ - strandedBidCount?: number; - strandedLockupDoos?: number; + strandedBidCount: number; + strandedLockupDoos: number; /** * Losing reveals a REDEEM would reclaim on this name, and what they are * worth. "Redeem" is a covenant name, not an explanation — the button says