diff --git a/SECURITY.md b/SECURITY.md index 532136a..b7bc139 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -251,7 +251,7 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market #### 5. Price steps signed by the lock key (selling) -**Mitigation:** The lock key signs a price step (`0x84`, `SINGLEREVERSE|ANYONECANPAY`) only in `commands/shakedex.rs::finalize_and_sign_confirmed`, behind the `shakedex_finalize_and_sign` command, and only after the user confirms the Finalize & sign prompt in the secure window. Before the prompt it makes one `0x84` signature, the R18 self-check's (`sell::lock_self_check`), over a test template with a zero outpoint: it is verified and dropped, never stored or sent. The prompt is `commands/secure_confirm.rs::confirm_rows`, on `prompt_secure`: its own `confirm` request, not the transaction prompt, listing the FINALIZE's network fee, every step with its price and when it becomes valid, the payment address, the lock address and the warning that each signature lets anyone buy at its price until a cancel is mined. A declined prompt signs and writes nothing. Before it asks, `prepare_lock_finalize` refuses unless the listing is the active profile's and ReadyToFinalize, the node reports the name still held, unrevoked, by the lock TRANSFER after its lockup, and that confirmed TRANSFER commits to the lock of the key re-derived from the seed for the name, whose public key must be the one stored on the listing (R18; the key is derived after every node read, held in memory until the command returns, never stored). Each step's template has one input, the lock coin the FINALIZE into the lock creates (that FINALIZE's txid, which Handshake fixes before signing, and the index of its one FINALIZE output paying the lock address, `sell::lock_output`), with that output's value, and one output paying the price to the listing's reserved payment address; its lock time is the node's median time minus 512 seconds, read from the node just before the prompt (with the other node reads, not after it), and nothing is signed when the node does not report a median time. `sell::sign_step` signs that template with the lock key and refuses a signature that does not verify against it (sighash `0x84`, low-S, the lock key's public key). The FINALIZE and every step are signed in one hold of the unlocked session; the signed FINALIZE draft, the steps, the listing file and the listing's move to Finalizing are written in one database transaction, and nothing is sent: `commands/tx.rs::broadcast_tx_draft` sends the FINALIZE through the usual broadcast gates. A step over a FINALIZE that is never mined can never be spent, since any other FINALIZE has another txid; `shakedex_export_listing_file` refuses to hand out the listing file while the listing is Locking, ReadyToFinalize or Finalizing, that is before its FINALIZE is mined, and for a listing that is over (Aborted, Expired, Cancelled, Sold) or Restored. +**Mitigation:** The lock key signs a price step (`0x84`, `SINGLEREVERSE|ANYONECANPAY`) only in `commands/shakedex.rs::finalize_and_sign_confirmed`, behind the `shakedex_finalize_and_sign` command, and only after the user confirms the Finalize & sign prompt in the secure window. Before the prompt it makes one `0x84` signature, the R18 self-check's (`sell::lock_self_check`), over a test template with a zero outpoint: it is verified and dropped, never stored or sent. The prompt is `commands/secure_confirm.rs::confirm_rows`, on `prompt_secure`: its own `confirm` request, not the transaction prompt, listing the FINALIZE's network fee, every step with its price and when it becomes valid, the payment address, the lock address and the warning that each signature lets anyone buy at its price until a cancel is mined. A declined prompt signs and writes nothing. Before it asks, `prepare_lock_finalize` refuses unless the listing is the active profile's and ReadyToFinalize, the node reports the name still held, unrevoked, by the lock TRANSFER after its lockup, and that confirmed TRANSFER commits to the lock of the key re-derived from the seed for the name, whose public key must be the one stored on the listing (R18; the key is derived after every node read, held in memory until the command returns, never stored). Each step's template has one input, the lock coin the FINALIZE into the lock creates (that FINALIZE's txid, which Handshake fixes before signing, and the index of its one FINALIZE output paying the lock address, `sell::lock_output`), with that output's value, and one output paying the price to the listing's reserved payment address; its lock time is the node's median time minus 512 seconds, read from the node just before the prompt (with the other node reads, not after it), and nothing is signed when the node does not report a median time. `sell::sign_step` signs that template with the lock key and refuses a signature that does not verify against it (sighash `0x84`, low-S, the lock key's public key). The FINALIZE and every step are signed in one hold of the unlocked session; the signed FINALIZE draft, the steps, the listing file and the listing's move to Finalizing are written in one database transaction, and nothing is sent: `commands/tx.rs::broadcast_tx_draft` sends the FINALIZE through the usual broadcast gates. A step over a FINALIZE that is never mined can never be spent, since any other FINALIZE has another txid; `shakedex_export_listing_file` refuses to hand out the listing file while the listing is Locking, ReadyToFinalize or Finalizing, that is before its FINALIZE is mined, and for a listing that is over (Aborted, Expired, Cancelled, Sold) or Restored. Restore a lock by name (R32, `shakedex_restore_lock`) derives the lock key from the unlocked session only to read its public key and lock address, after the node reads; it signs nothing. Importing a Restored lock's own listing file (R32, `shakedex_import_own_listing_file`) derives it the same way, after the lock coin is read, only to compare its public key with the file's; it signs nothing either. --- diff --git a/docs/REGTEST_TESTING.md b/docs/REGTEST_TESTING.md index 2485915..d4dbb64 100644 --- a/docs/REGTEST_TESTING.md +++ b/docs/REGTEST_TESTING.md @@ -309,7 +309,7 @@ These drive REAL reorgs via a `#[cfg(test)]` `invalidateblock`/`reconsiderblock` ### A node without a transaction index (Group C, second node) -hsd finds a transaction by txid only in its mempool and its transaction index, so on a node without `--index-tx` every mined transaction reads as hsd's "Transaction not found.". These tests need a second regtest node started with the address index alone, on ports of its own; they are skipped while `HNS_IT_NOINDEX_NODE_URL` is unset or empty: +hsd finds a transaction by txid only in its mempool and its transaction index, so on a node without `--index-tx` every mined transaction reads as hsd's "Transaction not found.". These tests need a second regtest node started with the address index alone, on ports of its own; they are skipped while `HNS_IT_NOINDEX_NODE_URL` is unset or empty. The node mines its own chain (it does not connect to the first node); `live_noindex_listing_sold_is_detected` also registers a name and runs both lockups on it, about 250 blocks a run: ```bash hsd --network=regtest --index-address --no-wallet --listen=false \ @@ -326,6 +326,7 @@ HNS_IT_NOINDEX_NODE_URL=http://127.0.0.1:24037 HNS_IT_NOINDEX_NODE_API_KEY=test | `live_noindex_unsent_draft_with_unspent_coins_is_dropped` | A draft the node never had, its coins unspent, is `dropped` and its coins released | | `live_noindex_coins_spent_by_another_tx_give_no_verdict` | A draft whose coin another transaction spent is not sent and stays `broadcast_pending`: without the index the wallet cannot tell that from a mined one | | `live_noindex_dropped_send_mined_after_all_is_confirmed` | A send judged `dropped` that is then mined is `confirmed`, found by its outputs | +| `live_noindex_listing_sold_is_detected` | Namehold sells, a second Namehold profile buys; with the purchase not found by txid, the listing is Sold through the name's owner (the purchase's TRANSFER out of our lock) and our payment coin; a database copy that syncs only after the buyer's FINALIZE is Sold from the block its payment coin was mined in | ### Covenant actions (Group D) @@ -380,7 +381,7 @@ HNS_IT_NODE_URL=http://127.0.0.1:14037 HNS_IT_NODE_API_KEY=test HNS_IT_SHAKEDEX= cargo test --manifest-path src-tauri/Cargo.toml --lib live_node_it::shakedex_ -- --test-threads=1 --nocapture ``` -The tests drive the CLI through `scripts/shakedex-cli-sell.sh`, with the node's hsd wallet as the seller and as the other buyer. Each run registers fresh names (open, two bids, reveal, register, mining between the phases), so a used chain works, up to a point: regtest halves the block subsidy every 2500 blocks (`halvingInterval`), and the tests fund their wallets by mining, so a chain some tens of thousands of blocks tall leaves them too little to spend. A full run of the live suite mines about 10 000 blocks (the Shakedex tests about 3000 of them, registering names and funding buyers), so start over after two runs; the Shakedex buyer refuses to start on a chain past 30 000 blocks and says why; `scripts/regtest.sh reset` starts over. Each test funds an account of its own (`seeded_conn_regtest` hands out a fresh one): the tests once shared account 0, which gathered thousands of coinbases a run, until a sweep of it outgrew a standard transaction and stalled every later test behind an unmined transaction. The R9 test below mines about 5000 blocks and takes them back out with `invalidateblock` before it asserts anything, so it leaves the chain as tall as it found it. The R31 test (`shakedex_lock_refused_near_expiry`) does the same toward its own name's expiry, mining to an address no profile derives, and a guard takes the blocks back out even if the test panics first. The lock tests (`shakedex_lock_*`, `shakedex_lock_finalize_pays_its_fee_rate_on_vsize` among them, `shakedex_cancel_transfer_aborts_the_listing_on_chain`, `shakedex_listing_follows_reorg_of_its_lock_finalize` and `shakedex_finalize_from_another_device_is_a_restored_lock`) are gated by `HNS_IT_SHAKEDEX` like the rest, but use no CLI: they register a name with the wallet itself, so they also run against a node at another port. The script clones `shadstoneofficial/shakedex` at the pinned commit `2c4fa04eab68a528e758598d11b5da5666113b11` into `SHAKEDEX_WORK` once and keeps the CLI's database there (the tests default it to `$TMPDIR/namehold-shakedex-cli`; run by hand, the script uses a fresh temp dir unless it is set); nothing is installed globally. `HNS_IT_SHAKEDEX` set to anything but `1` or empty, or set to `1` without `HNS_IT_NODE_URL`, or with a node URL other than the regtest RPC port (14037), fails the tests instead of skipping them. It can be run by hand too: +The tests drive the CLI through `scripts/shakedex-cli-sell.sh`, with the node's hsd wallet as the seller and as the other buyer. Each run registers fresh names (open, two bids, reveal, register, mining between the phases), so a used chain works, up to a point: regtest halves the block subsidy every 2500 blocks (`halvingInterval`), and the tests fund their wallets by mining, so a chain some tens of thousands of blocks tall leaves them too little to spend. A full run of the live suite mines about 10 000 blocks (the Shakedex tests about 3000 of them, registering names and funding buyers), so start over after two runs; the Shakedex buyer refuses to start on a chain past 30 000 blocks and says why; `scripts/regtest.sh reset` starts over. Each test funds an account of its own (`seeded_conn_regtest` hands out a fresh one): the tests once shared account 0, which gathered thousands of coinbases a run, until a sweep of it outgrew a standard transaction and stalled every later test behind an unmined transaction. The R9 test below mines about 5000 blocks and takes them back out with `invalidateblock` before it asserts anything, so it leaves the chain as tall as it found it. The R31 test (`shakedex_lock_refused_near_expiry`) does the same toward its own name's expiry, mining to an address no profile derives, and a guard takes the blocks back out even if the test panics first; `shakedex_listed_lock_expires_with_its_name` mines about 5000 blocks to its Listed name's expiry under the same guard. The lock tests (`shakedex_lock_*`, `shakedex_lock_finalize_pays_its_fee_rate_on_vsize` among them, `shakedex_cancel_transfer_aborts_the_listing_on_chain`, `shakedex_listing_follows_reorg_of_its_lock_finalize`, `shakedex_finalize_from_another_device_is_a_restored_lock`, `shakedex_listed_lock_expires_with_its_name` and `shakedex_restore_lock_by_name_after_a_fresh_profile`) are gated by `HNS_IT_SHAKEDEX` like the rest, but use no CLI: they register a name with the wallet itself, so they also run against a node at another port. The script clones `shadstoneofficial/shakedex` at the pinned commit `2c4fa04eab68a528e758598d11b5da5666113b11` into `SHAKEDEX_WORK` once and keeps the CLI's database there (the tests default it to `$TMPDIR/namehold-shakedex-cli`; run by hand, the script uses a fresh temp dir unless it is set); nothing is installed globally. `HNS_IT_SHAKEDEX` set to anything but `1` or empty, or set to `1` without `HNS_IT_NODE_URL`, or with a node URL other than the regtest RPC port (14037), fails the tests instead of skipping them. It can be run by hand too: | Command | What the CLI does | |---|---| @@ -395,8 +396,12 @@ What the tests check: - `shakedex_cli_listing_is_bought` — a fixed-price listing is bought: unconfirmed in the mempool, awaiting finalize once mined, owned after the lockup and the FINALIZE, with the node's owner coin paying the purchase's destination. - `shakedex_namehold_buy_now_is_bought_by_the_cli` — Namehold locks a fresh name, waits out the lockup (the listing jobs make the listing ready only at the last block), Finalize & signs a Buy Now, sends and mines the FINALIZE (Listed once the jobs see it mined), exports the listing file, and the CLI's hsd wallet buys that file byte for byte with `fill`; hsd shows the name's owner as the fill's TRANSFER out of our lock coin, committing to an address of that wallet, and the fill paying our payment address exactly the price. -- `shakedex_listing_follows_reorg_of_its_lock_finalize` (no CLI) — a Listed listing's FINALIZE into the lock has its block invalidated; while hsd has it nowhere (`invalidateblock` empties the mempool) the listing stays Listed, handed back to the node it is a coin in the mempool and the listing is Finalizing, and mined again it is Listed. +- `shakedex_listing_follows_reorg_of_its_lock_finalize` (no CLI) — a Listed listing's FINALIZE into the lock has its block invalidated; while hsd has it nowhere (`invalidateblock` empties the mempool) the lock TRANSFER is a coin again and the listing is Finalizing; handed back to the node it is a coin in the mempool and the listing stays Finalizing; mined again, it is Listed. - `shakedex_finalize_from_another_device_is_a_restored_lock` (no CLI) — a copy of the wallet's database, as another device restored from the same recovery phrase, Finalize & signs and mines the FINALIZE; the first wallet's listing, still ReadyToFinalize, becomes a Restored lock with that FINALIZE's lock outpoint, read from hsd's owner coin. +- `shakedex_namehold_listing_sold_to_the_cli_is_sold` — the CLI's hsd wallet buys our exported Buy Now; hsd's owner is the fill's TRANSFER out of our lock, the fill pays our payment address the price, and after a sync the listing is Sold with the fill's txid; the fill's block invalidated, the lock coin is a coin again and the listing Listed; the fill handed back, SalePending with the owner still the lock coin; mined again, Sold. +- `shakedex_listed_lock_expires_with_its_name` (no CLI) — a Listed lock's name is mined to hsd's `renewalPeriodEnd` (the FINALIZE into the lock's block plus the renewal window) to an address no profile derives: one block before, the listing stays Listed; at it hsd reports no live name while the lock coin is still a coin, and the listing is Expired. The blocks are taken back out, by a guard even if the test panics. +- `shakedex_restore_lock_by_name_after_a_fresh_profile` (no CLI) — a fresh profile from the same phrase and account, with a restore's address window, restores the Listed lock by name, as hsd's owner coin, a FINALIZE at the lock address derived from the phrase; a second restore is refused; the first device's saved file, imported after a sync, makes it Listed with that file's payment address. The payment address sits inside the default gap, so the test does not exercise the known restore-window gap (spec R21 and §5: the window is never extended, and a payment address reserved past it is refused until it is); that case is not tested live. +- `shakedex_restored_lock_by_name_bought_is_sold` (no CLI) — a fresh profile from the same phrase restores the Listed lock by name, so it knows no payment address; a Namehold buyer buys the first device's file: in the mempool (lock coin hsd's 404, owner still the lock coin) the restored lock stays Restored, and once mined (owner the purchase's TRANSFER out of the lock, linked from the stored outpoint) it is Sold with the purchase's txid. - `shakedex_finalized_name_moved_on_before_a_sync_is_owned` — the purchase is finalized and the name then sent elsewhere (TRANSFER, lockup, FINALIZE) before the purchase job runs again; the job finds our FINALIZE as the TRANSFER's spender in the node's history of our destination and marks the purchase owned. The shakedex lock script lets the TRANSFER be spent only into a FINALIZE, so there is no other ending to test. - `shakedex_cli_buyer_first_loses_ours_with_nothing_paid` — the CLI buys the listing between our review and our broadcast. hsd 8.0.0's `sendrawtransaction` answers with the txid anyway; the look-up after it finds the node did not take the purchase, so the broadcast says "did not take" and the draft waits as `broadcast_pending`. The purchase job then finds the lock coin spent and loses the purchase with nothing paid, its coins free again, and the listing is no longer offered. - `shakedex_purchase_follows_reorgs_of_its_own_blocks` — the purchase's block is invalidated (hsd's `invalidateblock` empties the mempool too, so the purchase is gone from the node), the app's sync rebroadcasts it once after six blocks missing, and it is mined again; then the FINALIZE's block is invalidated and the purchase awaits finalize again until the same FINALIZE is mined. diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index fe59c9d..c413071 100644 --- a/docs/specs/2026-10-05-shakedex-name-sales.md +++ b/docs/specs/2026-10-05-shakedex-name-sales.md @@ -114,8 +114,8 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI *Pinned:* `shakedex_sell_tests::{refuses_lock_on_self_check_failure, refuses_finalize_on_commitment_mismatch}`, `noncustodial::shakedex::sell` unit test `lock_self_check_fails_on_a_wrong_key`. **R19 — Selling is two clicks around the transfer lockup.** Sell is an action in `NameActionsModal` (Owned Names), and the listing's state is shown on the name's row there; the Market page's "My listings" tab holds every action on the seller's listings and "Restore a lock by name" (R32). Day 0: choose Buy Now or Reverse auction, the price or schedule, and "Publish on LearnHNS Market" (on by default); the dialog states R27's costs; Lock name builds a TRANSFER committing to the lock address (it stays at the seller's own address until finalized) and needs the unlocked signer. Between day 0 and the FINALIZE the abort is the existing Cancel transfer (an UPDATE); it also withdraws the pending listing. After the lockup a notification, repeated daily until acted on; Finalize & sign prices builds the FINALIZE to the lock and signs every price step in one unlock, with a last chance to change the price; it re-checks R31 first, since it may run days after the lock. Prices are refused in the backend, with the reason, when they are 0, below the dust limit, above `MAX_MONEY` or written with more than 6 decimals. The listing is published once that FINALIZE has one confirmation. A Buy Now's lock time is the MTP minus 512 s, so it is valid at once by R3; the MTP is read at Finalize & sign, just before its confirmation, not when the name was locked. -*Enforced:* `commands/shakedex.rs::{shakedex_build_lock_draft, shakedex_finalize_and_sign, prepare_lock_finalize, finalize_and_sign_confirmed}` (Finalize & sign checks, asks once (R20), then in one hold of the unlocked session signs the FINALIZE and every step over the lock coin that FINALIZE creates, `sell::lock_output`, and writes the signed FINALIZE draft, the steps, the listing file and the listing's move to Finalizing in one database transaction; it sends nothing, the FINALIZE is sent by `commands/tx.rs::broadcast_tx_draft`), `noncustodial/shakedex/sell.rs::{build_lock_finalize_plan, buy_now_lock_time}` (a lock-targeting finalize; the generic finalize accepts only P2WPKH targets), `address` encoding for 32-byte programs, `commands/deadlines.rs` (new kind `listing_ready`, "Ready to finalize", repeated daily while the listing is ReadyToFinalize, or Finalizing with its FINALIZE draft not sent yet: `draft` or `signed`), `src/components/NameActionsModal.tsx`, `src/components/market/MyListings.tsx`; the abort: `commands/names.rs::build_cancel_draft` links the Cancel transfer to the listing whose lock TRANSFER it spends, in the draft's own database transaction (`db/queries.rs::link_shakedex_listing_abort`), and `shakedex_jobs.rs::refresh_listings_before_lock_with_client`, a step of `run_sync_steps` against an authoritative node in the app and in the daemon, resolves every listing still Locking or ReadyToFinalize, and every one Aborted within the re-check window, from chain facts. The linked cancel is read first, by the txid kept on the listing (kept when a broadcast cancel's draft is deleted): its UPDATE a mined coin makes the listing Aborted, and back in the mempool, or the lock TRANSFER a coin again, Locking. Otherwise hsd's `getnameinfo` and `GET /coin` decide alone: `info: null` makes the listing Expired; the lock TRANSFER `(lock_transfer_txid, 0)` neither the owner of an unrevoked name (`info.owner`, `info.revoked`; a REVOKE leaves `owner` where it was) nor a coin (hsd's empty 404), while the lock draft can no longer land (`confirmed`, `dropped`, `failed` or deleted), makes it Aborted — a Cancel transfer sent from any device, a REVOKE, a replaced cancel whose older one was mined, a lock TRANSFER that never landed; and an Aborted listing whose lock TRANSFER is a coin or the owner again is Locking again, unless another listing of the name is open by then. While the lock TRANSFER is the owner, a Locking listing becomes ReadyToFinalize once `NameParams::blocks_until_finalize` of hsd's `info.transfer` (the TRANSFER's block) and the tip is 0 (hsd judges the FINALIZE at tip + 1), and a ReadyToFinalize one is Locking again when it is not, or when the lock TRANSFER is a coin in the mempool (`height: -1`) and not the owner; a lock TRANSFER mined in a block but not the owner is not a consistent answer and changes nothing. Before aborting an unrevoked name, the owner coin is looked up with `GET /coin` and only its `address` and `covenant.type` are read: a FINALIZE at this listing's lock address (derived from its stored lock public key) is a FINALIZE into our own lock sent from elsewhere, and the listing becomes a Restored lock with that outpoint (`db/queries.rs::adopt_lock_finalized_elsewhere`), never Aborted; another covenant at the lock address leaves the listing as it is; and hsd's 404 for the owner coin, which `info.owner` names at the tip, means a mempool transaction spends it (`fullnode.js` `getCoin`), so it is no verdict until that spend is mined. `shakedex_jobs.rs::refresh_lock_finalize_with_client`, the next step of `run_sync_steps`, follows every Finalizing and Listed listing from hsd's `GET /coin` of its lock outpoint: a FINALIZE at the lock address mined in a block makes it Listed, the same coin in the mempool (`height: -1`) makes a Listed one Finalizing again, and a 404 for a Finalizing listing whose FINALIZE draft is `failed`, `dropped` or deleted returns it to ReadyToFinalize without its lock outpoint, steps and file while the lock TRANSFER is a coin again; when the lock TRANSFER is hsd's 404 too, something other than our FINALIZE spent it (an older Cancel transfer mined later, another device's FINALIZE into our lock, a REVOKE), and the listing is settled exactly as before the lock by the same code (`shakedex_jobs.rs::settle_left_lock`): `info: null` makes it Expired, the owner coin a FINALIZE at our lock address a Restored lock with that outpoint, the owner coin readable elsewhere or the name revoked Aborted, and the owner coin's 404 no verdict; each of these drops the dead FINALIZE's outpoint, steps and file. The database writes for Aborted, Expired and Restored (`db/queries.rs::{abort_shakedex_listing, expire_shakedex_listing, adopt_lock_finalized_elsewhere}`) accept a Finalizing listing only while its FINALIZE draft is dead (not `draft_alive`, or its row gone). Finalize & sign and the before-lock job ask whether the lock TRANSFER owns the name through one rule (`sell::lock_transfer_owns_name`: `info.revoked` 0 and `info.owner` the lock TRANSFER's output 0) and both count the lockup from hsd's `info.transfer` (`sell::transfer_height`), the field hsd's FINALIZE rule reads (`chain.js`, `height < ns.transfer + transferLockup`), not from the TRANSFER coin's height. The two jobs never take the same listing (`ListingState::BEFORE_LOCK_JOB`, Locking, ReadyToFinalize and Aborted listings, against `ListingState::FINALIZE_JOB`, Finalizing and Listed ones; neither takes a Restored listing), and a listing leaves Locking and ReadyToFinalize for Finalizing in the database transaction that writes its FINALIZE draft, so a FINALIZE into our lock is never read as an abort. Both only read the node, and a read error, a missing field (an owner coin without its address or covenant, a lock coin without its address, covenant or height, a lock TRANSFER coin without its height, a name without its TRANSFER height) or a 404 with a body leaves the listing as it is. -*Pinned:* `shakedex_sell_tests::{lock_draft_commits_to_the_derived_lock_address, cancel_transfer_aborts_the_listing, abort_needs_the_nodes_word, cancel_of_another_transfer_leaves_the_listing_alone, deleting_an_unsent_cancel_unlinks_it, deleted_dropped_cancel_mined_later_still_aborts, reorged_abort_leaves_a_newer_listing_open, sync_aborts_the_listing_in_the_app_and_the_daemon, a_cancel_of_another_output_of_the_lock_tx_links_nothing, external_cancel_aborts_the_listing, revoked_name_aborts_the_listing, dead_lock_draft_aborts_and_the_name_can_be_locked_again, replaced_cancel_aborts_the_listing, lock_draft_in_flight_without_its_coin_changes_nothing, name_without_live_state_expires_the_listing, aborted_listing_relocks_when_the_lock_transfer_is_back, missing_chain_facts_change_nothing, lock_then_finalize_and_sign, buy_now_valid_immediately, buy_now_lock_time_is_taken_at_signing, step_prices_out_of_range_are_refused, cancel_transfer_refused_once_finalize_and_sign_ran, finalize_and_sign_refused_before_the_lockup, finalize_and_sign_writes_all_or_nothing, finalize_and_sign_without_the_median_time_asks_and_writes_nothing, lockup_over_makes_the_listing_ready_to_finalize, ready_listing_goes_back_to_locking_on_a_reorg, finalize_into_our_lock_is_never_an_abort, finalize_into_our_lock_from_another_device_is_a_restored_lock, dropped_finalize_mined_after_all_is_a_restored_lock, mined_finalize_lists_the_listing_and_a_reorg_takes_it_back, refused_finalize_returns_the_listing_to_ready, finalize_facts_missing_change_nothing, owner_coin_spent_in_the_mempool_is_no_verdict, mined_lock_transfer_that_is_not_the_owner_changes_nothing, sync_lists_the_listing_in_the_app_and_the_daemon, a_dead_finalize_ends_its_listing_like_before_the_lock, dead_finalize_with_its_lock_transfer_spent_elsewhere_is_resolved_from_the_name, reorged_abort_of_a_dead_finalize_relocks_without_its_steps, job_listing_sets_are_disjoint, finalize_and_sign_refused_when_the_name_left_its_lock_transfer}`, `noncustodial::shakedex::sell` unit tests `buy_now_lock_time_is_valid_at_the_next_block`, `lock_output_is_the_plans_finalize_into_the_lock`, `deadlines_cmd_tests::{listing_ready_repeats, listing_ready_respects_disabled_setting}`, `shakedex_sell_tests::listings_ready_to_finalize_are_listed_for_the_reminder`, `read_cmd_tests::locked_name_listed_with_its_listing_state`, `my-listings.test.tsx`, and on a live node `tests/live_node_it.rs::{shakedex_lock_transfer_commits_to_the_lock_address, shakedex_listing_follows_reorg_of_its_lock_finalize, shakedex_finalize_from_another_device_is_a_restored_lock}`. +*Enforced:* `commands/shakedex.rs::{shakedex_build_lock_draft, shakedex_finalize_and_sign, prepare_lock_finalize, finalize_and_sign_confirmed}` (Finalize & sign checks, asks once (R20), then in one hold of the unlocked session signs the FINALIZE and every step over the lock coin that FINALIZE creates, `sell::lock_output`, and writes the signed FINALIZE draft, the steps, the listing file and the listing's move to Finalizing in one database transaction; it sends nothing, the FINALIZE is sent by `commands/tx.rs::broadcast_tx_draft`), `noncustodial/shakedex/sell.rs::{build_lock_finalize_plan, buy_now_lock_time}` (a lock-targeting finalize; the generic finalize accepts only P2WPKH targets), `address` encoding for 32-byte programs, `commands/deadlines.rs` (new kind `listing_ready`, "Ready to finalize", repeated daily while the listing is ReadyToFinalize, or Finalizing with its FINALIZE draft not sent yet: `draft` or `signed`), `src/components/NameActionsModal.tsx`, `src/components/market/MyListings.tsx`; the abort: `commands/names.rs::build_cancel_draft` links the Cancel transfer to the listing whose lock TRANSFER it spends, in the draft's own database transaction (`db/queries.rs::link_shakedex_listing_abort`), and the before-lock job (`shakedex_jobs.rs::refresh_before_lock`) of `shakedex_jobs.rs::refresh_listings_step` (`refresh_listings_with_client`), which `run_sync_steps` runs against an authoritative node in the app and in the daemon, resolves every listing still Locking or ReadyToFinalize, and every one Aborted within the re-check window, from chain facts. The linked cancel is read first, by the txid kept on the listing (kept when a broadcast cancel's draft is deleted): its UPDATE a mined coin makes the listing Aborted, and back in the mempool, or the lock TRANSFER a coin again, Locking. Otherwise hsd's `getnameinfo` and `GET /coin` decide alone: `info: null` makes the listing Expired; the lock TRANSFER `(lock_transfer_txid, 0)` neither the owner of an unrevoked name (`info.owner`, `info.revoked`; a REVOKE leaves `owner` where it was) nor a coin (hsd's empty 404), while the lock draft can no longer land (`confirmed`, `dropped`, `failed` or deleted), makes it Aborted — a Cancel transfer sent from any device, a REVOKE, a replaced cancel whose older one was mined, a lock TRANSFER that never landed; and an Aborted listing whose lock TRANSFER is a coin or the owner again is Locking again, unless another listing of the name is open by then. While the lock TRANSFER is the owner, a Locking listing becomes ReadyToFinalize once `NameParams::blocks_until_finalize` of hsd's `info.transfer` (the TRANSFER's block) and the tip is 0 (hsd judges the FINALIZE at tip + 1), and a ReadyToFinalize one is Locking again when it is not, or when the lock TRANSFER is a coin in the mempool (`height: -1`) and not the owner; a lock TRANSFER mined in a block but not the owner is not a consistent answer and changes nothing, unless its covenant's name height is not hsd's `info.height` (the name expired and was opened again), which makes the listing Expired. Before aborting an unrevoked name, the owner coin is looked up with `GET /coin` and only its `address` and `covenant` (type and name hash) are read: a FINALIZE of the name at this listing's lock address (derived from its stored lock public key) is a FINALIZE into our own lock sent from elsewhere, and the listing becomes a Restored lock with that outpoint (`db/queries.rs::adopt_lock_finalized_elsewhere`), never Aborted; anything else at the lock address (another covenant, or a covenant of another name, since every lock coin of a key sits at one address) leaves the listing as it is. Every verdict that a coin is in, or left through, this listing's lock reads one rule, `noncustodial/shakedex/sell.rs::ListingLock::holds` (the covenant type, the name hash, the lock address, and for a restore the name height): the owner coin here, the lock coin of the after-lock job, the FINALIZE a proven-lock sale ties to the listing, the purchase's TRANSFER (`sell::purchase_in`) and a restore by name (R32); and hsd's 404 for the owner coin, which `info.owner` names at the tip, means a mempool transaction spends it (`fullnode.js` `getCoin`), so it is no verdict until that spend is mined. The after-lock job of the same step (`shakedex_jobs.rs::refresh_after_lock`), run after the before-lock job, follows every Finalizing and Listed listing from hsd's `GET /coin` of its lock outpoint: a FINALIZE of the name at the lock address mined in a block makes it Listed, the same coin in the mempool (`height: -1`) makes a Listed one Finalizing again, and a 404 for a Finalizing listing whose FINALIZE draft is `failed`, `dropped` or deleted returns it to ReadyToFinalize without its lock outpoint, steps and file while the lock TRANSFER is a coin again; when the lock TRANSFER is hsd's 404 too, something other than our FINALIZE spent it (an older Cancel transfer mined later, another device's FINALIZE into our lock, a REVOKE), and the listing is settled exactly as before the lock by the same code (`shakedex_jobs.rs::settle_left_lock`): `info: null` makes it Expired, the owner coin a FINALIZE at our lock address a Restored lock with that outpoint, the owner coin readable elsewhere or the name revoked Aborted, and the owner coin's 404 no verdict; each of these drops the dead FINALIZE's outpoint, steps and file. A mined purchase of the dead FINALIZE's lock coin that pays the listing's payment address (R22) makes it Sold instead of Aborted, its outpoint kept. The database writes for Aborted, Expired and Restored (`db/queries.rs::{abort_shakedex_listing, expire_shakedex_listing, adopt_lock_finalized_elsewhere}`) accept a Finalizing listing only while its FINALIZE draft is dead (not `draft_alive`, or its row gone). Finalize & sign and the before-lock job ask whether the lock TRANSFER owns the name through one rule (`sell::lock_transfer_owns_name`: `info.revoked` 0 and `info.owner` the lock TRANSFER's output 0) and both count the lockup from hsd's `info.transfer` (`sell::transfer_height`), the field hsd's FINALIZE rule reads (`chain.js`, `height < ns.transfer + transferLockup`), not from the TRANSFER coin's height. The two jobs never take the same listing (`ListingState::BEFORE_LOCK_JOB`, Locking, ReadyToFinalize and Aborted listings, against `ListingState::AFTER_LOCK_JOB`, Finalizing, Listed, SalePending, Restored and Sold within `SOLD_RECHECK_DAYS`; `shakedex_jobs.rs::refresh_listings_with_client` reads both sets before either job runs, so a listing one job moves into the other's set is judged once per sync), and a listing leaves Locking and ReadyToFinalize for Finalizing in the database transaction that writes its FINALIZE draft, so a FINALIZE into our lock is never read as an abort. Before an unrevoked name's listing is Aborted, or left as it is for an owner coin at our lock under another covenant, `shakedex_jobs.rs::sale_of_left_lock` looks for a mined purchase (R22) and makes the listing Sold when it finds one: a listing with a lock outpoint through `find_sale`; one without (a dead FINALIZE's listing back at ReadyToFinalize, or a Locking one) only once hsd shows the coin a purchase out of our lock address spends as a FINALIZE of the name at this listing's lock address, in a block, whose input at the FINALIZE's index is this listing's lock TRANSFER (`finalize_into_lock`: `GET /tx`, or without the index the block at hsd's `info.renewal`, which a FINALIZE sets and a TRANSFER leaves), and `find_sale` then judges it with that coin as its lock outpoint, which the Sold write stores (`db/queries.rs::sell_listing_through_proven_lock`, from Locking or ReadyToFinalize without an outpoint only, `ListingWrite::ProvenLockSale`; the stored-outpoint sale `ListingWrite::Sell` does not take Locking). Every mined purchase found is tried in turn until one is proven; when none is, the listing is left as it is (never Aborted), and a sale write that moves no row is logged. Lock refuses a name whose open listing is a dead Locking row with 'an earlier lock … did not go through: the next sync ends it', and "already locked for sale" otherwise. Both only read the node, and a read error, a missing field (an owner coin without its address or covenant, a lock coin without its address, covenant or height, a lock TRANSFER coin without its height, a name without its TRANSFER height) or a 404 with a body leaves the listing as it is. +*Pinned:* `shakedex_sell_tests::{lock_draft_commits_to_the_derived_lock_address, a_coin_of_another_name_at_our_lock_is_never_adopted_or_listed, cancel_transfer_aborts_the_listing, abort_needs_the_nodes_word, cancel_of_another_transfer_leaves_the_listing_alone, deleting_an_unsent_cancel_unlinks_it, deleted_dropped_cancel_mined_later_still_aborts, reorged_abort_leaves_a_newer_listing_open, sync_aborts_the_listing_in_the_app_and_the_daemon, a_cancel_of_another_output_of_the_lock_tx_links_nothing, external_cancel_aborts_the_listing, revoked_name_aborts_the_listing, dead_lock_draft_aborts_and_the_name_can_be_locked_again, replaced_cancel_aborts_the_listing, lock_draft_in_flight_without_its_coin_changes_nothing, name_without_live_state_expires_the_listing, aborted_listing_relocks_when_the_lock_transfer_is_back, missing_chain_facts_change_nothing, lock_then_finalize_and_sign, buy_now_valid_immediately, buy_now_lock_time_is_taken_at_signing, step_prices_out_of_range_are_refused, cancel_transfer_refused_once_finalize_and_sign_ran, finalize_and_sign_refused_before_the_lockup, finalize_and_sign_writes_all_or_nothing, finalize_and_sign_without_the_median_time_asks_and_writes_nothing, lockup_over_makes_the_listing_ready_to_finalize, ready_listing_goes_back_to_locking_on_a_reorg, finalize_into_our_lock_is_never_an_abort, finalize_into_our_lock_from_another_device_is_a_restored_lock, dropped_finalize_mined_after_all_is_a_restored_lock, mined_finalize_lists_the_listing_and_a_reorg_takes_it_back, refused_finalize_returns_the_listing_to_ready, finalize_facts_missing_change_nothing, owner_coin_spent_in_the_mempool_is_no_verdict, mined_lock_transfer_that_is_not_the_owner_changes_nothing, reopened_name_expires_a_listing_before_the_lock, dropped_finalize_mined_and_bought_before_a_sync_is_sold, lock_refused_while_a_dead_lock_waits_for_the_sync, sync_lists_the_listing_in_the_app_and_the_daemon, a_dead_finalize_ends_its_listing_like_before_the_lock, dead_finalize_with_its_lock_transfer_spent_elsewhere_is_resolved_from_the_name, reorged_abort_of_a_dead_finalize_relocks_without_its_steps, job_listing_sets_are_disjoint, finalize_and_sign_refused_when_the_name_left_its_lock_transfer}`, `noncustodial::shakedex::sell` unit tests `buy_now_lock_time_is_valid_at_the_next_block`, `lock_output_is_the_plans_finalize_into_the_lock`, `deadlines_cmd_tests::{listing_ready_repeats, listing_ready_respects_disabled_setting}`, `shakedex_sell_tests::listings_ready_to_finalize_are_listed_for_the_reminder`, `read_cmd_tests::locked_name_listed_with_its_listing_state`, `my-listings.test.tsx`, and on a live node `tests/live_node_it.rs::{shakedex_lock_transfer_commits_to_the_lock_address, shakedex_listing_follows_reorg_of_its_lock_finalize, shakedex_finalize_from_another_device_is_a_restored_lock}`. **R20 — The signer shows everything it is about to make permanent.** The combined Finalize & sign confirmation (a new secure prompt, not the transaction one) lists the FINALIZE's fee and every step's price and the time it becomes valid, and says each signature lets anyone buy at that price until the listing is cancelled and the cancel is mined. *Enforced:* `commands/shakedex.rs::shakedex_finalize_and_sign` via `commands/secure_confirm.rs::confirm_rows` (a `confirm` request of its own on `prompt_secure`, rows from `noncustodial/shakedex/sell.rs::finalize_and_sign_rows`); no price step is signed and nothing is written unless it is confirmed. Before the prompt, `prepare_lock_finalize` reads the node's median time for the steps' lock time (so the times the prompt shows are the ones signed) and runs the R18 self-check, whose test `0x84` signature is over a template with a zero outpoint and is verified and dropped. @@ -125,9 +125,9 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI *Enforced:* `noncustodial/derivation.rs::reserve_receive_address` (reserve on allocation, returning the address with its receive-branch index), called twice by `commands/shakedex.rs::build_lock_draft_inner` in the listing's own transaction; the cancel address's index is stored on the listing (`shakedex_listings.cancel_child_index`). *Pinned:* `derivation_tests::reserved_addresses_are_not_reissued`, `shakedex_sell_tests::lock_reserves_payment_and_cancel_addresses`. -**R22 — Sold means the lock coin was bought.** A listing is Sold when its lock coin is spent by a TRANSFER that commits to an address not ours, in a transaction whose payment output pays our listing address. Coins arriving at the payment address on their own mean nothing. -*Enforced:* `shakedex_jobs.rs::refresh_listings_step` from `run_sync_steps`. -*Pinned:* `shakedex_listing_state_tests::{sold_on_purchase, payment_alone_is_not_sold, sale_pending_while_the_purchase_is_in_the_mempool, listing_follows_a_reorg_of_its_lock_finalize, sold_reverts_on_a_reorg_of_the_purchase, name_reply_without_owner_leaves_the_listing_as_it_is, expired_lock_is_expired_not_sold, daemon_refresh_makes_no_send_and_no_market_call}`, and on a live node `tests/live_node_it.rs::{shakedex_namehold_listing_sold_to_the_cli_is_sold, shakedex_listing_follows_reorg_of_its_lock_finalize, live_noindex_listing_sold_is_detected}` (the last on a node without `--index-tx`). +**R22 — Sold means the lock coin was bought.** A listing is Sold when its lock coin is spent by a TRANSFER that commits to an address not ours, in a transaction whose payment output pays our listing address. A lock restored by name, which knows no payment address (R32), is Sold instead when its lock coin is spent, in a mined transaction, by a price step's signature (sighash `0x84`) into a TRANSFER of the name committing to an address not ours. Coins arriving at the payment address on their own mean nothing. +*Enforced:* `shakedex_jobs.rs::refresh_listings_step` from `run_sync_steps`, in the app and the daemon; its after-lock job reads each Finalizing, Listed, SalePending and Restored listing, and each Sold one within `SOLD_RECHECK_DAYS`. hsd's 404 for the lock coin (spent in a block or the mempool) is never a verdict alone: the name is read, and a purchase is looked for (`shakedex_jobs.rs::find_sale`) only for a listing with a payment address and a lock outpoint. Every verdict rests on the purchase transaction found on chain and judged by `noncustodial/shakedex/sell.rs::purchase_in` against the listing's stored lock outpoint (every lock coin of a name sits at the same lock address, ADR 0004, so the address alone never says which listing was bought), and the lock coin written with the sale is the input `purchase_in` found. The transaction is found with `GET /tx` (mempool, or the index) or, on hsd's not-found, in the block at a known height (`getblockhash` + `getblock`, no index). Two ways lead to it: the name's owner first, when the wallet has a coin of that transaction at the payment address and hsd's `GET /coin` shows the owner as a TRANSFER of the name at our lock address committing to an address not ours (`sell::commitment_is_ours`; committing to ours it is our cancel, no verdict) and mined in a block, its transaction read at the owner coin's own height; otherwise each transaction that paid the listing's payment address (a coin of ours there in `tracked_utxos`, spent or not, which is a lead and never proof by itself), read at the height our coin of it was seen mined (a coin row whose transaction is in neither is no verdict). A transaction that cannot be read is logged and skipped, which can only withhold a verdict. Found in the mempool while the name's owner is still the lock coin: SalePending; mined while the owner has moved: Sold, with the purchase's txid (`sold_txid`), and a Sold listing whose purchase is still that txid is not written again, so it leaves the after-lock set once `SOLD_RECHECK_DAYS` pass; a purchase reported mined while the owner is still the lock coin, or in the mempool while it has moved, is two hsd facts that disagree and no verdict. The database writes (`db/queries.rs::{mark_listing_sale_pending, sell_shakedex_listing}`) take the lock coin the purchase spends and move a listing only when that coin is the listing's stored lock outpoint: a listing without one is never moved by them (ReadyToFinalize, which never carries one, is not among their source states), and only `sell_listing_through_proven_lock` (R19) gives a listing an outpoint on sale. A reorg moves states back only on hsd's showing a coin again, never because a purchase is not found: the lock coin a coin again (hsd answers 404 for a coin any mempool transaction spends, so the purchase is in no block and no mempool) makes a SalePending or Sold listing Listed (Finalizing when that coin is in the mempool and the listing has our FINALIZE draft, so its file is not exported over an unmined FINALIZE; Restored when it has no listing file), its purchase forgotten, unless another listing of the name is open by then (`db/queries.rs::unsell_shakedex_listing`); the lock TRANSFER a coin again (the FINALIZE into the lock in no block and no mempool) makes a Listed listing, and a SalePending or Sold one with our FINALIZE draft, Finalizing, a SalePending or Sold one adopted from another device's FINALIZE (no draft here) Restored, and a Restored one adopted from this device's lock TRANSFER Locking without its outpoint (`unadopt_restored_lock`). A Sold listing whose purchase hsd puts back in its mempool after a reorg (`mempool._removeBlock`: the lock coin 404, the owner the lock coin again, the purchase at -1) is SalePending again, unless another listing of the name is open by then; another purchase of the same lock coin mined instead replaces the sale's txid. Both are Sold's own guarded write (`resell_sold_listing`: from Sold only, for the listing's own lock outpoint), so Sold is in no other write's source set besides Unsell's (a reorg back, above) and Resell's. A Listed or Restored listing whose lock coin is a coin and whose name has no live state (`info: null`), or whose name height is not its lock coin's (the name expired and was opened again), is Expired, keeping its lock outpoint (`expire_locked_listing`, from Listed, SalePending or Restored); with the lock coin spent, only `info: null` expires it, a SalePending listing as well (a purchase still in the mempool when the name expired), even when a real purchase spent it (the app closed until the buyer's registration lapsed): the listing is then Expired, not Sold, and the payment is in the wallet all the same. A Restored lock without a payment address (restored by name, R32) has no payment output to find a purchase by: it is Sold, with that txid, only on positive evidence in the transaction that spent its stored lock outpoint, the name's owner (read with `GET /tx`, or without the index in the block at the owner coin's height): mined; input k the stored lock outpoint, its witness `[signature, lock script]` with the signature's sighash type (its last byte) `0x84`, `ANYONECANPAY | SINGLEREVERSE`, which only a price step carries (our cancel is `0x83`); output k a TRANSFER of the name at the lock address; output `len - 1 - k`, the one `0x84` commits to, a payment (covenant NONE); and, as a cheap extra check, the TRANSFER's commitment not one of our derived addresses (`noncustodial/shakedex/sell.rs::sale_out_of_restored_lock`, `shakedex_jobs.rs::sale_of_restored_lock`; `sell::SpendView` carries each input's witness from both of hsd's shapes, REST `inputs[].witness` and `getblock` `vin[].txinwitness`). A commitment "not ours" alone is no evidence: after a restore the derived addresses are not complete (§5), so another device's cancel to a cancel address past this device's window would look like a sale. A price step commits to its payment output, so the sale was paid to that step's address, even though this device does not know it. Any other sighash, a commitment to an address of ours (our cancel, T5), the mempool, another input coin, a transaction not found, or a lock input whose witness the reply leaves out or sends unreadable, is no verdict. If the buyer's FINALIZE is mined before a sync sees the purchase's TRANSFER as the owner, the owner's transaction is that FINALIZE, and following its input back to the TRANSFER is not done: the lock stays Restored (§5). A name, lock coin or owner coin reply missing a field the verdict reads, or a read error of one, leaves the listing as it is. Every write that moves a listing's state takes the states it moves from and to from one table, `db/queries.rs::ListingWrite::transition` (its `state IN` list, and a refusal of a target the table does not list); the writes that reopen a listing share one "no other listing of the name is open" condition, and the after-lock job chooses where a SalePending or Sold listing goes back to in one function (`shakedex_jobs.rs::unsell_target`). Every txid a listing stores is lowercase, as hsd writes hashes: each listing write normalises the txids it stores or compares (`db/queries.rs::listing_txid`), and the queries and the jobs compare txids exactly. The step sends nothing. +*Pinned:* `shakedex_listing_state_tests::{each_listing_write_moves_exactly_its_transitions, listing_txids_are_stored_lowercase, restored_lock_by_name_is_sold_by_a_mined_transfer_out_of_its_lock, a_restored_lock_is_sold_only_by_a_price_step_signature, a_listing_with_a_corrupted_lock_output_is_left_as_it_is, sold_on_purchase, payment_alone_is_not_sold, sale_pending_while_the_purchase_is_in_the_mempool, sold_after_the_buyer_finalized_is_found_from_the_purchase, an_unreadable_transaction_is_skipped, listing_follows_a_reorg_of_its_lock_finalize, sold_reverts_on_a_reorg_of_the_purchase, sold_follows_its_purchase_back_to_the_mempool_and_a_competing_purchase, restored_lock_follows_its_coin, name_reply_without_owner_leaves_the_listing_as_it_is, expired_lock_is_expired_not_sold, daemon_refresh_makes_no_send_and_no_market_call, a_stable_sold_listing_is_not_written_again}`, `shakedex_sell_tests::dropped_finalize_mined_and_bought_before_a_sync_is_sold` and `shakedex_listing_state_tests::proven_lock_sale_moves_only_a_listing_before_the_lock_without_an_outpoint` (a listing without a lock outpoint, see R19), the `noncustodial::shakedex::sell` unit tests `sale_out_of_restored_lock_needs_every_link` and `spend_view_reads_input_witnesses_from_both_shapes`, and on a live node `tests/live_node_it.rs::{shakedex_namehold_listing_sold_to_the_cli_is_sold, shakedex_listing_follows_reorg_of_its_lock_finalize, shakedex_listed_lock_expires_with_its_name, shakedex_restored_lock_by_name_bought_is_sold, live_noindex_listing_sold_is_detected}` (the last on a node without `--index-tx`). **R23 — Publishing.** With the box ticked and a mainnet profile only: a pending listing on day 0 (`POST /api/v2/pending-listings`), then the active listing after R19 (`POST /api/upload-proof`, multipart field `proof`). LearnHNS accepts one price step per upload and `lockTime <= now + 2 h`. We write `expiresAt = MTP + 365 days` and refresh it before it lapses. The listing file is always saved and can be exported whatever the box says. The help text says publishing makes the name, price and lock public, and that the market sees our IP on every check. Publishing runs from `run_sync_steps` in the app and in `namehold-syncd` (R25). *Enforced:* `market/learnhns.rs` (refuses any write unless the profile network is main), `shakedex_jobs.rs::publish_listings_step`, `commands/shakedex.rs::shakedex_export_listing_file` (allowed only in `ListingState::EXPORT_ALLOWED`: Listed, SalePending, Cancelling, CancelAwaitingFinalize and CancelFinalizing; refused while the listing is Locking, ReadyToFinalize or Finalizing, since the file leaves the wallet only once its FINALIZE into the lock is mined, and refused with the state's own reason when it is Aborted, Expired, Cancelled, Sold or Restored), `noncustodial/shakedex/listing_file.rs::write_listing_file` (read back by the strict parser `ListingFile::parse` before it is returned). @@ -147,7 +147,7 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI **R27 — The seller is told what locking costs.** In the Lock dialog: once finalized into the lock, the name's DNS records cannot be changed and it cannot be renewed (the lock script allows only TRANSFER and FINALIZE out), and the listing lasts at most `renewalWindow` after the FINALIZE into the lock, which renews the name (hsd v8.0.0 sets the name's renewal height on every FINALIZE; mainnet about two years). The dialog also carries R31's near-expiry warning. While a name is locking or locked, UPDATE, RENEW, TRANSFER, Finalize and batch actions on it are not offered; the new owner-spend actions join `owner_spend_in_flight`. *Enforced:* `src/components/market/LockDialog.tsx`, `commands/names.rs` capability gates and `refuse_while_listed` in the Update, Renew, Transfer, Finalize and Revoke builders, batch ones and Finalize with payment included (a REVOKE of the lock TRANSFER is valid in hsd and would burn the name); Cancel transfer stays offered and built while the listing is Locking or ReadyToFinalize (R19's abort) and is refused from Finalizing on. Both read `db::queries::listing_blocking_owner_actions`: a Locking listing blocks only while its lock TRANSFER draft is alive (`draft`, `signed`, `broadcast_pending`, `broadcasted`, `confirmed`), so a dropped, failed or deleted lock draft does not freeze the name; a listing past Locking always blocks. `WalletView.tsx` offers Renew Selected only when every selected name may be renewed. -*Pinned:* `names_cmd_tests::locked_name_offers_no_owner_actions`, `names_action_context_tests::find_name_action_context_counts_a_lock_transfer_as_an_owner_spend`, `names_action_context_tests::a_locking_listing_blocks_only_while_its_lock_draft_is_alive`, `names_cmd_tests::dead_lock_draft_does_not_freeze_the_name`, `batch-reveal-redeem-finalize.test.tsx` (Renew Selected), `lock-dialog.test.tsx` (the costs text). +*Pinned:* `names_cmd_tests::locked_name_offers_no_owner_actions`, `read_cmd_tests::{renewals_leave_out_a_locked_name, renewals_leave_out_a_name_locking_at_our_address}` (Renewals leaves out a locked name, and a Locking one while its lock draft is alive), `names_action_context_tests::find_name_action_context_counts_a_lock_transfer_as_an_owner_spend`, `names_action_context_tests::a_locking_listing_blocks_only_while_its_lock_draft_is_alive`, `names_cmd_tests::dead_lock_draft_does_not_freeze_the_name`, `batch-reveal-redeem-finalize.test.tsx` (Renew Selected), `lock-dialog.test.tsx` (the costs text). **R28 — Cancel.** Needs the unlocked signer. The lock key signs a `0x83` TRANSFER at the lock address committing to a reserved address of ours (R21); after the lockup a FINALIZE (notification, button, R14's pattern) brings the name home. The dialog says that until the cancel is mined a buyer can still take the current price, and that if a purchase is already in our node's mempool, the cancel is not taken and never mined, though hsd still answers it with its txid. Once a cancel is broadcast, R24 and R25 stop for that listing and the market is told (`POST /api/v2/listings//refresh-status`). If a purchase wins, the cancel's reserved coins are released and the listing is Sold. Re-listing a name after a cancel reuses its lock key and lock address (ADR 0004); the new listing tracks only its own lock outpoint, and nothing of the old one attaches to it. *Enforced:* `noncustodial/shakedex/cancel.rs::{build_cancel_plan, build_cancel_finalize_plan}`, `commands/shakedex.rs::{shakedex_cancel_listing, shakedex_finalize_cancel}`, `commands/deadlines.rs` (new kind `cancel_finalize`). @@ -161,9 +161,9 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI *Enforced:* `noncustodial/shakedex/sell.rs::lock_expiry_guard` (via `noncustodial/network.rs::NameParams::expiry_end`), called from `shakedex_build_lock_draft` and `shakedex_finalize_and_sign`, and again when the lock TRANSFER or the FINALIZE into the lock is sent (`commands/tx.rs::broadcast_tx_draft` through `commands/draft_ctx.rs::recheck_lock_expiry`, from the tip then, with the full lockup for the lock TRANSFER and none for the FINALIZE; a refused lock TRANSFER discards its unsent draft with its listing, a refused FINALIZE keeps its signed draft and its Finalizing listing, and neither sends anything); the warning text in `src/components/market/LockDialog.tsx`. *Pinned:* `shakedex_sell_tests::{lock_refused_when_the_name_would_expire_during_the_lockup, lock_broadcast_refused_when_the_name_would_expire_during_the_lockup, finalize_broadcast_refused_when_the_name_would_expire_first, lock_warns_below_six_months, finalize_and_sign_refused_when_the_name_would_expire_first}`, `lock-dialog.test.tsx` (the warning and the refusal), and on a live node `tests/live_node_it.rs::shakedex_lock_refused_near_expiry` (the boundary block on both sides, against hsd's own `renewalPeriodEnd`). -**R32 — Restore a lock by name.** After a restore the wallet does not find its locks on its own (§5); the seller restores one by typing its name in "My listings". The wallet derives the name's lock key (the recovery phrase is needed, since a hardened key cannot come from the xpub) and adopts the lock only when the name's current owner coin in `getnameinfo`, read with `GET /coin`, sits at `lock_address(derived pub)` and carries FINALIZE at the name's height; anything else is refused with the reason: an expired name (`getnameinfo` has no `info`), a name not at our lock, a name still Locking (the TRANSFER toward the lock is mined but the FINALIZE into it is not, so the owner coin is still at the seller's own address; the restore does not adopt it, and the seller's way out is Cancel transfer), a lock coin that is already a TRANSFER rather than FINALIZE (a cancel awaiting its finalize, or a mined purchase), and a lock coin left over from an earlier registration. The adopted lock is a Restored lock, "Locked — listing details unknown", keyed by name and its lock outpoint, with Cancel offered (R28). Importing a listing file whose lock outpoint and public key match it makes it a full listing; a file for another key or another lock coin is refused. -*Enforced:* `commands/shakedex.rs::{shakedex_restore_lock, shakedex_import_own_listing_file}`. -*Pinned:* `shakedex_sell_tests::{restore_lock_by_name_adopts_a_lock_at_the_derived_address, restore_lock_refuses_a_name_not_at_our_lock, restore_lock_refuses_a_leftover_lock_coin, own_listing_file_upgrades_a_restored_lock, own_listing_file_for_another_key_is_refused}`, `my-listings.test.tsx` (restore by name shows "Locked — listing details unknown"), and on a live node `tests/live_node_it.rs::shakedex_restore_lock_by_name_after_a_fresh_profile` (same phrase, new profile). +**R32 — Restore a lock by name.** After a restore the wallet does not find its locks on its own (§5); the seller restores one by typing its name in "My listings". The wallet derives the name's lock key (the recovery phrase is needed, since a hardened key cannot come from the xpub) and adopts the lock only when the name's current owner coin in `getnameinfo`, read with `GET /coin`, sits at `lock_address(derived pub)` and carries FINALIZE at the name's height; anything else is refused with the reason: an expired name (`getnameinfo` has no `info`), a name not at our lock, a name still Locking (a TRANSFER committing to the lock is mined but the FINALIZE into it is not, so the owner coin is still at the address that sent it, which the restore does not compare with ours; the restore does not adopt it, and the way out is Cancel transfer from the wallet that holds the name), a lock coin that is already a TRANSFER rather than FINALIZE (a cancel awaiting its finalize, or a mined purchase), and a lock coin left over from an earlier registration. The adopted lock is a Restored lock, "Locked — listing details unknown", keyed by name and its lock outpoint, with Cancel offered (R28). Importing a listing file whose lock outpoint and public key match it makes it a full listing; a file for another key or another lock coin is refused. Until then, a mined TRANSFER of the name out of its lock coin signed with a price step's sighash `0x84` (our cancel is `0x83`) into a TRANSFER committing to an address not ours makes it Sold (R22): a price step commits to its payment output, so the sale was paid to the step's address even though this device does not know it. +*Enforced:* `commands/shakedex.rs::{shakedex_restore_lock, restore_lock_inner}` (recovery-phrase profiles only, R16, a node that can send, as for Lock (`software_writer_ctx`; a Restored lock's only action, Cancel, sends), and the unlocked signer, all checked before the node is read; the lock key is derived after the node reads, for its public key and lock address only; refused while a listing of the name is open or when a listing of the profile already holds that lock coin), `noncustodial/shakedex/sell.rs::{restore_owner, restore_verdict}` (each refusal in its own sentence; a revoked name, `info.revoked` not 0, is refused as well; hsd's null owner is not at our lock; the owner coin's 404 is "being spent in the node's mempool", no verdict; a reply without the owner, the name height or `revoked`, or a coin without its address, covenant or height, in the mempool, of another outpoint than the owner asked for, of another name, or without a readable name height, is "could not check" and writes nothing), `commands/shakedex.rs::{shakedex_import_own_listing_file, restored_lock_for_file, upgrade_restored_lock_inner}` (recovery-phrase profiles only and the unlocked signer, checked before the file is read; the file is read by the strict listing-file parser, size limit first; a file naming a market fee (a `feeAddr` that is not null, or any step `fee`) is refused, since this wallet writes `feeAddr: null` and every fee 0; only a Restored lock of the file's name is upgraded, and only by a file carrying its lock outpoint and public key (`db/queries.rs::upgrade_restored_lock` guards both and the Restored state again), whose public key is the lock key derived from the seed for the name, account and network after the node read, and whose payment address is one of the profile's derived addresses on this network; while hsd's `GET /coin` still finds the lock coin, every step must verify over it, at the value hsd reports, with `template::verify_step_signature`, and a coin of another outpoint, without its address, at another address than the lock's, or with a negative value is "could not check"; while hsd answers 404 for the lock coin (spent in a block or the mempool) the steps are not verified — publishing re-verifies them (T6) — and the outpoint and key are all that is checked, the chain judging R22 by the file's payment address; the listing then becomes Listed with the file's payment address, steps, expiry and mode, the file kept as written; nothing is written on a refusal). +*Pinned:* `shakedex_sell_tests::{restore_lock_by_name_adopts_a_lock_at_the_derived_address, restore_lock_refuses_a_name_not_at_our_lock, restore_lock_refuses_a_leftover_lock_coin, restore_lock_refuses_an_expired_name, restore_lock_refuses_a_name_still_locking, restore_lock_refuses_a_lock_coin_already_a_transfer, restore_lock_refuses_a_revoked_name, restore_lock_refuses_an_owner_coin_spent_in_the_mempool, restore_lock_refuses_a_lock_coin_a_listing_already_has, restore_lock_refuses_a_name_with_an_open_listing, restore_lock_with_a_node_reply_missing_a_field_writes_nothing, restore_lock_needs_the_unlocked_signer, restore_lock_refused_for_ledger_and_watch_only, restore_lock_refused_without_write_capability, own_listing_file_upgrades_a_restored_lock, own_listing_file_for_another_key_is_refused, own_listing_file_with_a_step_not_signed_by_the_lock_is_refused, own_listing_file_for_a_spent_lock_coin_checks_only_the_outpoint_and_key, own_listing_file_with_a_key_this_wallet_does_not_derive_is_refused, own_listing_file_paying_an_address_not_ours_is_refused, own_listing_file_with_a_market_fee_is_refused, own_listing_file_over_the_size_limit_is_refused, own_listing_file_without_a_restored_lock_is_refused, own_listing_file_with_a_node_reply_missing_a_field_writes_nothing, own_listing_file_needs_the_unlocked_signer, own_listing_file_refused_for_ledger_and_watch_only}`, `shakedex_listing_state_tests::restored_lock_by_name_is_sold_by_a_mined_transfer_out_of_its_lock`, `my-listings.test.tsx` (restore by name shows "Locked — listing details unknown"), and on a live node `tests/live_node_it.rs::{shakedex_restore_lock_by_name_after_a_fresh_profile, shakedex_restored_lock_by_name_bought_is_sold}` (same phrase, new profile). ### 3.4 Interoperability @@ -186,9 +186,10 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI - **Background jobs need a running process.** Republishing, stepping reverse auctions and finalize reminders run while the app or `namehold-syncd` runs; reminders run only in the app. The listing file keeps working regardless. - **Recovery of a lock is manual.** After a restore the wallet does not find its locks on its own: the seller restores each one by name (R32) or imports its saved listing file. Finding them automatically would mean deriving lock addresses from the names in the wallet's own address history (ADR 0004), but today's sync does not read address history on the node path and caps it on the explorer path, explorers exist only on mainnet, and a remote node without `--index-tx` cannot do it. That scan is deferred to the roadmap; this stage adds none. Restore by name does not adopt a name still Locking or a cancel awaiting its finalize (R32), so after a restore a name still Locking is left to the ordinary Cancel transfer, and the wallet offers no FINALIZE for a cancel that was awaiting its finalize. - **A restore does not extend its address window.** A restore provisions a fixed window of addresses (`address_gap_limit`) and does not extend it, so payment or cancel addresses past it are not seen until the window is raised. -- **A restored lock has no listing details.** Price steps, schedule and payment address are off-chain. A Restored lock (R32) shows "Locked — listing details unknown: import its listing file, or cancel"; the saved listing file is the backup. +- **A dead FINALIZE's sale is not found without the transaction index once the buyer has finalized.** A listing back at ReadyToFinalize (or still Locking) whose lock was finalized, bought and finalized by the buyer before any sync stays as it is, logged on every sync, on a node without `--index-tx`: our FINALIZE is then readable neither with `GET /tx` nor at `info.renewal`, which names the buyer's FINALIZE (R19). +- **A restored lock has no listing details.** Price steps, schedule and payment address are off-chain. A Restored lock (R32) shows "Locked — listing details unknown: import its listing file, or cancel"; the saved listing file is the backup. A lock restored by name has no payment address; it is followed to Sold through the name's owner only, on a price step's `0x84` signature over its lock coin (R22, R32). If the buyer's FINALIZE is mined before a sync sees the purchase's TRANSFER as the name's owner, the owner's transaction is that FINALIZE, and following its input back to the TRANSFER is not done: the lock stays Restored until its file is imported, or for good. - **One device per listing.** Devices or profiles sharing a seed derive the same lock keys but not the same listing state; their background jobs would fight over uploads, and one device's Lower price is invisible to the other. -- **A node without a transaction index sees less.** The app's own hsd runs with `--index-tx`; a remote node may not. Without it hsd does not find a mined purchase, so the purchase is followed through its TRANSFER coin and the name's owner instead. A purchase lost while paying nothing and finalized since is shown as owned only once its own transaction is found, which needs the index: on such a node it stays lost, though the name arrives through normal sync. +- **A node without a transaction index sees less.** The app's own hsd runs with `--index-tx`; a remote node may not. Without it hsd does not find a mined purchase, so the purchase is followed through its TRANSFER coin and the name's owner instead. A purchase lost while paying nothing and finalized since is shown as owned only once its own transaction is found, which needs the index: on such a node it stays lost, though the name arrives through normal sync. A sale is found without it, through the name's owner while the purchase's TRANSFER is unspent, and after the buyer's FINALIZE from the block our payment coin was seen mined in; a payment coin the wallet only ever saw in the mempool, spent before it was seen mined, leaves such a listing unjudged. - **No fee bump.** hsd has no replace-by-fee; an underpaid purchase can only be helped by spending its change (CPFP), which the wallet does not offer. - **No keep-alive for long listings.** The lock key could restart the renewal clock by transferring the lock coin to itself and finalizing, at the cost of re-signing every step; not offered. - **Pending listings can be overwritten by anyone** on LearnHNS; only active listings are defended (R25). diff --git a/src-tauri/src/commands/names.rs b/src-tauri/src/commands/names.rs index 389118e..e141aaf 100644 --- a/src-tauri/src/commands/names.rs +++ b/src-tauri/src/commands/names.rs @@ -2639,7 +2639,8 @@ pub async fn build_cancel_draft( let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; // R19: a Cancel transfer of a name still locking is its listing's abort. // The link commits with the draft; the listing becomes Aborted only once - // this cancel is mined (`shakedex_jobs::refresh_listings_before_lock_with_client`). + // this cancel is mined (the before-lock job of + // `shakedex_jobs::refresh_listings_with_client`). let tx = conn.unchecked_transaction()?; let label = DraftLabel { action: "cancel", diff --git a/src-tauri/src/commands/read.rs b/src-tauri/src/commands/read.rs index 7ea48f7..d33e7dc 100644 --- a/src-tauri/src/commands/read.rs +++ b/src-tauri/src/commands/read.rs @@ -456,6 +456,8 @@ pub async fn read_balance( /// /// Names bought through Shakedex are listed too while the purchase is /// unconfirmed or awaiting finalize, each with a `shakedex` object (R14). +/// Names with a Shakedex listing carry a `listing` object (R19); a name in our +/// lock is listed by its listing. #[tauri::command] pub async fn read_names( state: State<'_, AppState>, @@ -507,6 +509,45 @@ fn collect_read_names_data( } } + // R19: each name's listing state; a name in our lock is no longer a coin + // at our addresses, so it gets a row of its own at the lock address. + let tip = crate::noncustodial::sync::get_sync_height(conn, id)?; + // Returned, not hidden: `read_names` is user-triggered, and the + // listings' lock addresses belong to the profile's network. + let network = queries::profile_network(conn, id)?; + let rows = queries::read_shakedex_listing_names( + conn, + id, + network.name_params(), + tip, + crate::shakedex_jobs::SOLD_RECHECK_DAYS, + )?; + for r in rows { + let listing = serde_json::to_value(&r.listing)?; + if let Some(v) = out + .iter_mut() + .find(|v| v.get("name").and_then(|n| n.as_str()) == Some(r.name.as_str())) + { + if let Some(obj) = v.as_object_mut() { + obj.insert("listing".into(), listing); + } + } else { + // The lock address from the stored key; a key that does not + // decode leaves the address out rather than failing the list. + let lock_address = hex::decode(&r.lock_pubkey_hex) + .ok() + .and_then(|b| <[u8; 33]>::try_from(b).ok()) + .and_then(|pk| { + crate::noncustodial::shakedex::script::lock_address(network, &pk).ok() + }); + out.push(serde_json::json!({ + "name": r.name, "state": null, "height": null, "renewal": null, "owner": null, + "owner_address": lock_address, "registered": true, "expired": null, + "stats": null, "listing": listing, + })); + } + } + Ok(out) } @@ -1803,8 +1844,9 @@ pub(crate) fn compute_renewals( // Owned names — same union as `read_names` (node-synced cache + explorer // discoveries), so the Renewals screen covers exactly what the wallet owns. for v in collect_read_names_data(conn, profile_id, live_node_height.is_some())? { - // A Shakedex purchase still on its way is not ours to renew yet. - if v.get("shakedex").is_some() { + // A Shakedex purchase still on its way is not ours to renew yet; a + // locked or locking name is not renewed (R27), and a sold one is not ours. + if v.get("shakedex").is_some() || v.get("listing").is_some() { continue; } let Some(name) = v.get("name").and_then(|x| x.as_str()) else { diff --git a/src-tauri/src/commands/shakedex.rs b/src-tauri/src/commands/shakedex.rs index a57cd2a..5574b09 100644 --- a/src-tauri/src/commands/shakedex.rs +++ b/src-tauri/src/commands/shakedex.rs @@ -562,10 +562,20 @@ pub(crate) fn build_lock_draft_inner( ))); } if queries::open_shakedex_listing_for_name(conn, &ctx.profile_id, i.name)?.is_some() { - return Err(AppError::InvalidInput(format!( - "'{}' is already locked for sale", - i.name - ))); + // A Locking listing whose lock draft is dead holds the name's one + // open listing until the next sync ends it from the chain (R19); no + // state is written here without that evidence. + let dead = + queries::listing_blocking_owner_actions(conn, &ctx.profile_id, i.name)?.is_none(); + return Err(AppError::InvalidInput(if dead { + format!( + "an earlier lock of '{}' did not go through: the next sync ends it, then the \ + name can be locked again", + i.name + ) + } else { + format!("'{}' is already locked for sale", i.name) + })); } sell::lock_self_check(i.key, ctx.network)?; @@ -777,7 +787,7 @@ pub(crate) async fn prepare_lock_finalize( .ok_or_else(|| corrupted("payment address"))?; let owner = owner .filter(|o| { - o.txid.eq_ignore_ascii_case(&lock_transfer_txid) + o.txid == lock_transfer_txid && o.vout == 0 && o.covenant_type == i64::from(COV_TRANSFER) }) @@ -1121,8 +1131,8 @@ fn listing_over(state: ListingState) -> Option<&'static str> { } ListingState::Sold => Some("this name is sold: the listing is over"), ListingState::Restored => Some( - "this lock was finalized by another wallet with the same recovery phrase: it has \ - no signed prices or listing file here; import its saved file", + "this lock was restored by name, or finalized by another wallet with the same \ + recovery phrase: it has no signed prices or listing file here; import its saved file", ), _ => None, } @@ -1153,6 +1163,244 @@ pub(crate) fn export_listing_file_from_conn( }) } +/// What a restore by name (R32) decides from: every node read, and the lock +/// key already in hand. +pub(crate) struct RestoreInput<'a> { + pub(crate) profile_id: &'a str, + pub(crate) network: Network, + pub(crate) name: &'a str, + pub(crate) key: &'a LockKey, + pub(crate) owner: &'a sell::RestoreOwner, + pub(crate) coin: &'a rpc::NodeCoin, +} + +/// R32: adopt the name's owner coin as a Restored lock when it is our lock +/// ([`sell::restore_verdict`]), keyed by name and that outpoint, with the +/// key's public key and no listing details (its mode is recorded as Buy Now +/// until its file is imported). Refused while a listing of the name is open, +/// or when a listing of the profile already holds that lock coin. Writes the +/// listing only. +pub(crate) fn restore_lock_inner( + conn: &rusqlite::Connection, + i: &RestoreInput, +) -> Result { + if queries::open_shakedex_listing_for_name(conn, i.profile_id, i.name)?.is_some() { + return Err(AppError::InvalidInput(format!( + "'{}' is already tracked by one of this wallet's listings", + i.name + ))); + } + if queries::shakedex_listing_holds_lock_coin(conn, i.profile_id, &i.owner.txid, i.owner.vout)? { + return Err(AppError::InvalidInput( + "this lock coin is already tracked by one of this wallet's listings".into(), + )); + } + sell::restore_verdict(i.network, i.name, &i.key.address, i.owner, i.coin)?; + let l = ShakedexListing { + id: random_id(), + wallet_profile_id: i.profile_id.into(), + name: i.name.into(), + mode: ListingMode::BuyNow, + state: ListingState::Restored, + lock_pubkey_hex: hex::encode(i.key.pubkey), + lock_transfer_draft_id: None, + lock_finalize_draft_id: None, + lock_transfer_txid: None, + lock_txid: Some(i.owner.txid.clone()), + lock_vout: Some(i64::from(i.owner.vout)), + payment_address: None, + cancel_address: None, + cancel_child_index: None, + steps_json: "[]".into(), + listing_file_json: None, + publish: false, + market_status: None, + market_retry_at: None, + expires_at: None, + abort_draft_id: None, + abort_txid: None, + sold_txid: None, + cancel_txid: None, + created_at: String::new(), + updated_at: String::new(), + }; + queries::insert_shakedex_listing(conn, &l)?; + let l = queries::get_shakedex_listing(conn, &l.id)? + .ok_or_else(|| AppError::Other("listing vanished after the restore".into()))?; + ListingSummary::of(&l) +} + +/// R32: the active profile's Restored lock that `file` (our own listing +/// file, already parsed) is for: a Restored lock of the file's name (a +/// listing of it in any other state, or none, is refused) whose lock key and +/// lock outpoint the file carries, and a file paying one of this profile's +/// derived addresses (the parser has checked its network). Reads the +/// database only. +pub(crate) fn restored_lock_for_file( + conn: &rusqlite::Connection, + profile_id: &str, + file: &ListingFile, +) -> Result { + let listing = queries::open_shakedex_listing_for_name(conn, profile_id, &file.name)? + .filter(|l| l.state == ListingState::Restored) + .ok_or_else(|| { + AppError::InvalidInput(format!( + "there is no Restored lock of '{}' here: restore it by name first", + file.name + )) + })?; + if hex::encode(file.public_key) != listing.lock_pubkey_hex { + return Err(AppError::InvalidInput( + "this listing file is for another lock key".into(), + )); + } + let lock_txid = hex::encode(file.lock_txid); + if listing.lock_txid.as_deref() != Some(lock_txid.as_str()) + || listing.lock_vout != Some(i64::from(file.lock_vout)) + { + return Err(AppError::InvalidInput( + "this listing file is for another lock coin".into(), + )); + } + if !queries::get_profile_addresses(conn, profile_id)?.contains(&file.payment_addr) { + return Err(AppError::InvalidInput( + "this listing file's payment address is not one of this wallet's addresses".into(), + )); + } + Ok(listing) +} + +/// The value of the lock coin hsd reports for `file`'s lock outpoint, read +/// field by field: a coin of another outpoint, without its address, at +/// another address than `lock_address`, or with a negative value, is "could +/// not check". +fn lock_coin_value( + coin: &rpc::NodeCoin, + file: &ListingFile, + lock_address: &str, +) -> Result { + let lock_txid = hex::encode(file.lock_txid); + if !(coin.txid == lock_txid && coin.vout == file.lock_vout) { + return Err(AppError::Rpc(format!( + "node answered coin {}:{} for the lock coin {lock_txid}:{}", + coin.txid, coin.vout, file.lock_vout + ))); + } + let Some(address) = coin.address.as_deref() else { + return Err(AppError::Rpc( + "node did not report the lock coin's address".into(), + )); + }; + if address != lock_address { + return Err(AppError::Rpc(format!( + "node reported the lock coin at {address}, not at this lock's address" + ))); + } + u64::try_from(coin.value).map_err(|_| { + AppError::Rpc(format!( + "node reported a lock coin value of {} doos", + coin.value + )) + }) +} + +/// What the upgrade of a Restored lock decides from: the listing and the +/// file already matched ([`restored_lock_for_file`]), the lock coin as hsd +/// reports it (`None`: its 404, spent), and the lock key derived from the +/// seed after the node read. +pub(crate) struct OwnFileInput<'a> { + pub(crate) network: Network, + pub(crate) listing: &'a ShakedexListing, + pub(crate) file: &'a ListingFile, + pub(crate) key: &'a LockKey, + pub(crate) coin: Option<&'a rpc::NodeCoin>, +} + +/// R32: upgrade a Restored lock with its own listing file. The file's public +/// key must be the lock key this wallet derives for the name; while the lock +/// coin is a coin, every step must also be signed by that key over it +/// (`template::verify_step_signature`, at the coin's value as hsd reports +/// it). A spent lock coin leaves the outpoint and key to check: the chain +/// then judges the sale (R22) by the file's payment address. The listing +/// becomes Listed with the file's payment address, steps, expiry and mode; +/// the file is kept as parsed (`ListingFile::to_json`, unknown fields +/// included). Writes nothing on a refusal. +pub(crate) fn upgrade_restored_lock_inner( + conn: &rusqlite::Connection, + i: &OwnFileInput, +) -> Result { + let file = i.file; + if file.public_key != i.key.pubkey { + return Err(AppError::InvalidInput(format!( + "this listing file's public key is not the lock key this wallet derives for '{}'", + file.name + ))); + } + if let Some(coin) = i.coin { + let lock_value = lock_coin_value(coin, file, &i.key.address)?; + let payment = output_address_from_string(i.network, &file.payment_addr)?; + for step in &file.steps { + template::verify_step_signature( + &template::StepTemplate { + lock_outpoint: (file.lock_txid, file.lock_vout), + lock_value, + lock_pubkey: &file.public_key, + payment: payment.clone(), + price: step.price, + lock_time_secs: step.lock_time, + }, + &step.signature, + ) + .map_err(|_| { + AppError::InvalidInput( + "a price in this listing file is not signed by this lock".into(), + ) + })?; + } + } + let steps: Vec = file + .steps + .iter() + .map(|s| sell::StoredStep { + price: s.price, + lock_time: s.lock_time, + signature: hex::encode(s.signature), + }) + .collect(); + let mode = if file.steps.len() == 1 { + ListingMode::BuyNow + } else { + ListingMode::ReverseAuction + }; + let expires_at = file + .expires_at + .map(i64::try_from) + .transpose() + .map_err(|_| AppError::InvalidInput("the listing file's expiry is out of range".into()))?; + let n = queries::upgrade_restored_lock( + conn, + &i.listing.id, + &hex::encode(file.lock_txid), + file.lock_vout, + &hex::encode(file.public_key), + &queries::UpgradedListing { + mode, + payment_address: &file.payment_addr, + steps_json: &serde_json::to_string(&steps)?, + listing_file_json: &file.to_json()?, + expires_at, + }, + )?; + if n != 1 { + return Err(AppError::InvalidInput( + "this lock changed meanwhile: nothing was saved; try again".into(), + )); + } + let l = queries::get_shakedex_listing(conn, &i.listing.id)? + .ok_or_else(|| AppError::Other("listing vanished after the import".into()))?; + ListingSummary::of(&l) +} + // --- commands --------------------------------------------------------------- /// One page of LearnHNS Market listings, each verified on the profile's node. @@ -1728,6 +1976,92 @@ pub async fn shakedex_export_listing_file( export_listing_file_from_conn(&conn, &profile.id, &listing_id) } +/// Restore a lock by name (R32): the name's owner coin is read on the node +/// (`getnameinfo`, then `GET /coin`), the lock key of `name` is derived from +/// the seed of the unlocked session after those reads (for its public key +/// and lock address only; nothing is signed), and the coin is adopted as a +/// Restored lock when it is a FINALIZE at that key's lock address +/// ([`restore_lock_inner`]). Acts on the active profile, which must be +/// seed-backed (R16: Ledger and watch-only profiles cannot derive the +/// hardened lock key); no experimental flag (the lock exists already, R15). +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub async fn shakedex_restore_lock( + state: State<'_, AppState>, + name: String, +) -> Result { + let ctx = software_writer_ctx(&state)?; + authorize_signer(&state, &ctx)?; + let reply = ctx.node.get_name_info(&name).await?; + let owner = sell::restore_owner(&reply, &name)?; + let coin = ctx + .node + .get_coin(&owner.txid, owner.vout) + .await? + .ok_or_else(|| AppError::InvalidInput(sell::RESTORE_SPENT_IN_MEMPOOL.into()))?; + let key = derive_listing_key(&state, &ctx, &name)?; + let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; + restore_lock_inner( + &conn, + &RestoreInput { + profile_id: &ctx.profile_id, + network: ctx.network, + name: &name, + key: &key, + owner: &owner, + coin: &coin, + }, + ) +} + +/// Import our own saved listing file for a Restored lock (R32): the file is +/// read by the strict parser (size limit first), a file charging a market +/// fee is refused (we never write one), the Restored lock it is for is found +/// ([`restored_lock_for_file`]), the lock coin is read with `GET /coin`, the +/// lock key of the name is derived from the seed of the unlocked session +/// after that read (to compare its public key; nothing is signed), and the +/// lock is upgraded ([`upgrade_restored_lock_inner`]). Acts on the active +/// profile, which must be seed-backed (R16); no experimental flag (R15). +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub async fn shakedex_import_own_listing_file( + state: State<'_, AppState>, + text: String, +) -> Result { + let ctx = software_writer_ctx(&state)?; + authorize_signer(&state, &ctx)?; + let file = ListingFile::parse(&text, ctx.network)?; + // This wallet writes `feeAddr: null` and every `fee` 0 + // (`listing_file::write_listing_file`). + if file.names_a_fee_address() || file.steps.iter().any(|s| s.fee != 0) { + return Err(AppError::InvalidInput( + "this listing file names a market fee, which this wallet never writes: it is not \ + this wallet's own listing file" + .into(), + )); + } + let listing = { + let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; + restored_lock_for_file(&conn, &ctx.profile_id, &file)? + }; + let coin = ctx + .node + .get_coin(&hex::encode(file.lock_txid), file.lock_vout) + .await?; + let key = derive_listing_key(&state, &ctx, &file.name)?; + let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; + upgrade_restored_lock_inner( + &conn, + &OwnFileInput { + network: ctx.network, + listing: &listing, + file: &file, + key: &key, + coin: coin.as_ref(), + }, + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index ba44364..7e51839 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -552,17 +552,11 @@ pub async fn run_sync_steps( if node_authoritative { crate::shakedex_jobs::refresh_purchases_step(db_path, profile_id, caller.rebroadcast()) .await; - // R19: a listing before the FINALIZE into the lock is Aborted or - // Expired from chain facts. Reads the node only, so the daemon runs it - // as the app does. - crate::shakedex_jobs::refresh_listings_before_lock_step(db_path, profile_id).await; - // R19: a listing whose FINALIZE into the lock is built is Listed once - // it is mined, Finalizing again on a reorg, and ReadyToFinalize again - // if it never landed. Reads the node only. It runs after the step - // above, and the two never take the same listing: that one reads - // Locking, ReadyToFinalize and Aborted listings, this one Finalizing - // and Listed ones. - crate::shakedex_jobs::refresh_lock_finalize_step(db_path, profile_id).await; + // R19, R22: every listing's state from chain facts — before the + // FINALIZE into the lock (Aborted, Expired, ReadyToFinalize, + // Restored) and after it (Listed, SalePending, Sold). Reads the node + // and the database only, so the daemon runs it as the app does. + crate::shakedex_jobs::refresh_listings_step(db_path, profile_id).await; } } diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index cf84307..5345ae9 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1400,10 +1400,11 @@ pub fn delete_tx_draft_in_tx(tx: &rusqlite::Transaction<'_>, id: &str) -> Result tx.execute( &format!( "UPDATE shakedex_listings SET {} - WHERE lock_finalize_draft_id = ?1 AND state = ?2", - revert_to_ready_set() + WHERE lock_finalize_draft_id = ?1 AND {}", + revert_to_ready_set(), + ListingWrite::RevertToReady.source_sql() ), - params![id, ListingState::Finalizing], + params![id], )?; // A Cancel transfer aborts nothing, so its listing loses the link and // the cancel's txid (R19). @@ -1563,11 +1564,18 @@ impl ListingState { pub const BEFORE_LOCK_JOB: [ListingState; 3] = [Self::Locking, Self::ReadyToFinalize, Self::Aborted]; - /// The states the finalize job reads - /// ([`list_shakedex_listings_finalizing`]). Disjoint from - /// [`Self::BEFORE_LOCK_JOB`], and neither holds Restored: no listing is - /// moved by both jobs in one sync. - pub const FINALIZE_JOB: [ListingState; 2] = [Self::Finalizing, Self::Listed]; + /// The states the after-lock job reads ([`list_shakedex_listings_after_lock`]): + /// the FINALIZE into the lock built or mined (Finalizing, Listed), a + /// purchase of the lock coin in the mempool (SalePending), a Restored + /// lock, and Sold within the re-check window, which a reorg may undo. + /// Disjoint from [`Self::BEFORE_LOCK_JOB`]. + pub const AFTER_LOCK_JOB: [ListingState; 5] = [ + Self::Finalizing, + Self::Listed, + Self::SalePending, + Self::Restored, + Self::Sold, + ]; /// The states whose listing file may leave the wallet (R23): its /// FINALIZE into the lock is mined and the listing is not over. Before @@ -1583,6 +1591,170 @@ impl ListingState { ]; } +/// Every guarded write that moves a listing from one state to another. Its +/// [`ListingWrite::transition`] is the one table of the states each write +/// moves from and to: every write's SQL takes its `state IN (..)` from it +/// ([`ListingWrite::source_sql`]), and a write whose target is an argument +/// refuses one the table does not list ([`ListingWrite::check_to`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListingWrite { + /// [`mark_listing_ready`]: the lockup is over. + Ready, + /// [`mark_listing_locking_again`]: a reorg undid the lockup. + LockingAgain, + /// [`mark_listing_finalizing_in_tx`]: Finalize & sign wrote the FINALIZE. + Finalize, + /// [`revert_listing_to_ready`], and deleting an unsent FINALIZE draft. + RevertToReady, + /// [`mark_listing_listed`]: the FINALIZE into the lock is mined. + Listed, + /// [`mark_listing_finalizing_again`]: a reorg took that FINALIZE away. + FinalizingAgain, + /// [`abort_shakedex_listing`]. + Abort, + /// [`expire_shakedex_listing`]: the name expired before the lock. + ExpireBeforeLock, + /// [`adopt_lock_finalized_elsewhere`]. + AdoptElsewhere, + /// [`unabort_shakedex_listing`]. + Unabort, + /// [`mark_listing_sale_pending`]. + SalePending, + /// [`sell_shakedex_listing`]. + Sell, + /// [`sell_listing_through_proven_lock`]. + ProvenLockSale, + /// [`resell_sold_listing`]. + Resell, + /// [`unsell_shakedex_listing`]. + Unsell, + /// [`expire_locked_listing`]: the name expired under the lock. + ExpireLocked, + /// [`unadopt_restored_lock`]. + Unadopt, + /// [`upgrade_restored_lock`]. + Upgrade, +} + +impl ListingWrite { + pub const ALL: [ListingWrite; 18] = [ + Self::Ready, + Self::LockingAgain, + Self::Finalize, + Self::RevertToReady, + Self::Listed, + Self::FinalizingAgain, + Self::Abort, + Self::ExpireBeforeLock, + Self::AdoptElsewhere, + Self::Unabort, + Self::SalePending, + Self::Sell, + Self::ProvenLockSale, + Self::Resell, + Self::Unsell, + Self::ExpireLocked, + Self::Unadopt, + Self::Upgrade, + ]; + + /// The table: `(from, to)`, the states this write moves a listing from + /// and the states it may move it to. + /// + /// - Ending a listing before the lock (Abort, ExpireBeforeLock, + /// AdoptElsewhere) takes the states whose abort is still the Cancel + /// transfer, and Finalizing only while its FINALIZE draft is dead + /// ([`ends_before_lock_sql`]). + /// - A sale of the stored lock outpoint (SalePending, Sell) takes a + /// listing whose name is in our lock; ReadyToFinalize never carries an + /// outpoint, so its sale is ProvenLockSale's, which Locking is a source + /// for too. Sold is a source only for its own write, Resell. + /// - A name expiring under the lock (ExpireLocked) ends a Listed, + /// SalePending or Restored listing; Sold stays Sold, and a Finalizing + /// listing is judged by its FINALIZE first. + pub const fn transition(self) -> (&'static [ListingState], &'static [ListingState]) { + use ListingState as S; + const BEFORE_LOCK_END: &[ListingState] = &[S::Locking, S::ReadyToFinalize, S::Finalizing]; + const SALE: &[ListingState] = &[S::Finalizing, S::Listed, S::SalePending, S::Restored]; + match self { + Self::Ready => (&[S::Locking], &[S::ReadyToFinalize]), + Self::LockingAgain => (&[S::ReadyToFinalize], &[S::Locking]), + Self::Finalize => (&[S::ReadyToFinalize], &[S::Finalizing]), + Self::RevertToReady => (&[S::Finalizing], &[S::ReadyToFinalize]), + Self::Listed => (&[S::Finalizing], &[S::Listed]), + Self::FinalizingAgain => (&[S::Listed], &[S::Finalizing]), + Self::Abort => (BEFORE_LOCK_END, &[S::Aborted]), + Self::ExpireBeforeLock => (BEFORE_LOCK_END, &[S::Expired]), + Self::AdoptElsewhere => (BEFORE_LOCK_END, &[S::Restored]), + Self::Unabort => (&[S::Aborted], &[S::Locking]), + Self::SalePending => (SALE, &[S::SalePending]), + Self::Sell => (SALE, &[S::Sold]), + Self::ProvenLockSale => (&[S::Locking, S::ReadyToFinalize], &[S::Sold]), + Self::Resell => (&[S::Sold], &[S::SalePending, S::Sold]), + Self::Unsell => ( + &[S::SalePending, S::Sold], + &[S::Listed, S::Finalizing, S::Restored], + ), + Self::ExpireLocked => (&[S::Listed, S::SalePending, S::Restored], &[S::Expired]), + Self::Unadopt => (&[S::Restored], &[S::Locking]), + Self::Upgrade => (&[S::Restored], &[S::Listed]), + } + } + + /// The states this write moves a listing from. + pub const fn from(self) -> &'static [ListingState] { + self.transition().0 + } + + /// The states this write may move a listing to. + pub const fn to(self) -> &'static [ListingState] { + self.transition().1 + } + + /// `state IN (..)` of [`Self::from`], for the write's `WHERE`. + fn source_sql(self) -> String { + format!( + "state IN {}", + sql_list(self.from().iter().map(|s| s.as_str())) + ) + } + + /// The target of a write that has one. + fn target(self) -> ListingState { + match self.to() { + [one] => *one, + _ => unreachable!("{self:?} has more than one target"), + } + } + + /// Refuse a target the table does not list for this write: a bug in the + /// caller. + fn check_to(self, to: ListingState) -> Result<(), AppError> { + if self.to().contains(&to) { + Ok(()) + } else { + Err(AppError::Other(format!( + "{self:?} cannot move a listing to {to:?}" + ))) + } + } +} + +/// The `NOT EXISTS` condition of a write that reopens a listing: no other +/// listing of the same profile and name is open (not +/// [`ListingState::TERMINAL`]); `idx_shakedex_listings_open_name` allows one. +/// An SQL condition on `shakedex_listings`. +fn no_other_open_listing_sql() -> String { + format!( + "NOT EXISTS ( + SELECT 1 FROM shakedex_listings o + WHERE o.wallet_profile_id = shakedex_listings.wallet_profile_id + AND o.name = shakedex_listings.name AND o.id <> shakedex_listings.id + AND o.state NOT IN {})", + sql_list(ListingState::TERMINAL.iter().map(|s| s.as_str())) + ) +} + impl std::str::FromStr for ListingState { type Err = AppError; @@ -1727,6 +1899,13 @@ fn row_to_shakedex_listing(row: &rusqlite::Row<'_>) -> rusqlite::Result String { + txid.to_ascii_lowercase() +} + /// Insert a listing. The indexes refuse a second open listing of the name /// and a lock outpoint another listing already tracks. pub fn insert_shakedex_listing( @@ -1751,8 +1930,8 @@ pub fn insert_shakedex_listing( l.lock_pubkey_hex, l.lock_transfer_draft_id, l.lock_finalize_draft_id, - l.lock_transfer_txid, - l.lock_txid, + l.lock_transfer_txid.as_deref().map(listing_txid), + l.lock_txid.as_deref().map(listing_txid), l.lock_vout, l.payment_address, l.cancel_address, @@ -1764,9 +1943,9 @@ pub fn insert_shakedex_listing( l.market_retry_at, l.expires_at, l.abort_draft_id, - l.abort_txid, - l.sold_txid, - l.cancel_txid + l.abort_txid.as_deref().map(listing_txid), + l.sold_txid.as_deref().map(listing_txid), + l.cancel_txid.as_deref().map(listing_txid) ], )?; Ok(()) @@ -1807,6 +1986,27 @@ pub fn open_shakedex_listing_for_name( Ok(row) } +/// Whether any listing of the profile, in any state, holds the lock coin +/// `(txid, vout)`: a lock coin is tracked by one listing +/// (`idx_shakedex_listings_lock_outpoint`). +pub fn shakedex_listing_holds_lock_coin( + conn: &rusqlite::Connection, + profile_id: &str, + txid: &str, + vout: u32, +) -> Result { + let found = conn + .query_row( + "SELECT 1 FROM shakedex_listings + WHERE wallet_profile_id = ?1 AND lock_txid = ?2 AND lock_vout = ?3 + LIMIT 1", + params![profile_id, listing_txid(txid), i64::from(vout)], + |_| Ok(()), + ) + .optional()?; + Ok(found.is_some()) +} + /// The open listing that keeps `name`'s owner actions away (R27), if any. /// A listing past Locking always does. A Locking one does only while its /// lock TRANSFER draft is alive ([`draft_alive`]): a dropped, @@ -1821,12 +2021,111 @@ pub fn listing_blocking_owner_actions( let Some(listing) = open_shakedex_listing_for_name(conn, profile_id, name)? else { return Ok(None); }; - if listing.state != ListingState::Locking { - return Ok(Some(listing)); - } let status = lock_draft_status(conn, &listing)?; - let alive = status.is_some_and(|s| draft_alive(&s)); - Ok(alive.then_some(listing)) + let blocks = listing_holds_the_name(listing.state, status.as_deref()); + Ok(blocks.then_some(listing)) +} + +/// R27: whether an open listing in `state`, its lock TRANSFER draft in +/// `lock_draft_status` (`None`: no draft, or its row gone), keeps the name's +/// owner actions away and shows on the name's row: past Locking always; +/// Locking only while that draft is alive ([`draft_alive`]). +pub fn listing_holds_the_name(state: ListingState, lock_draft_status: Option<&str>) -> bool { + state != ListingState::Locking || lock_draft_status.is_some_and(draft_alive) +} + +/// The `listing` object on an Owned Names row (`ShakedexNameListing` in the UI). +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListingNameState { + pub listing_id: String, + pub state: ListingState, + /// Blocks left in the transfer lockup while Locking, from the lock + /// TRANSFER's confirmation height and the wallet's sync height (hsd + /// judges the FINALIZE at tip + 1); `None` before the TRANSFER is seen + /// mined and in every other state. + pub blocks_until_finalize: Option, +} + +/// One listing an Owned Names row shows. +pub struct ListingNameRow { + pub name: String, + pub lock_pubkey_hex: String, + pub listing: ListingNameState, +} + +/// The listing each of the profile's names shows on its Owned Names row: its +/// open listing (a Locking one only while its lock TRANSFER draft is alive, +/// as [`listing_blocking_owner_actions`]), else a Sold one within the last +/// `sold_days`; newest first per name. +pub fn read_shakedex_listing_names( + conn: &rusqlite::Connection, + profile_id: &str, + params_net: crate::noncustodial::network::NameParams, + tip: i64, + sold_days: u32, +) -> Result, AppError> { + let [t0, t1, t2, t3] = ListingState::TERMINAL; + let mut stmt = conn.prepare( + "SELECT l.id, l.name, l.state, l.lock_pubkey_hex, d.status, d.confirmation_height + FROM shakedex_listings l + LEFT JOIN wallet_tx_drafts d ON d.id = l.lock_transfer_draft_id + WHERE l.wallet_profile_id = ?1 + AND (l.state NOT IN (?2, ?3, ?4, ?5) + OR (l.state = ?6 AND l.updated_at >= datetime('now', ?7))) + ORDER BY l.name, l.created_at DESC, l.id", + )?; + let rows = stmt.query_map( + params![ + profile_id, + t0, + t1, + t2, + t3, + ListingState::Sold, + format!("-{sold_days} days") + ], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, ListingState>(2)?, + r.get::<_, String>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + )) + }, + )?; + let mut out: Vec = Vec::new(); + for row in rows { + let (id, name, state, pubkey, status, confirmed) = row?; + if out.iter().any(|r| r.name == name) { + continue; + } + if !listing_holds_the_name(state, status.as_deref()) { + continue; + } + // A display estimate: the lock TRANSFER draft's confirmation height + // as the wallet's sync recorded it and the wallet's sync height. The + // listing's state itself moves on hsd's `info.transfer` (the + // before-lock job), which a reorg can change before the sync does. + let blocks_until_finalize = match (state, status.as_deref(), confirmed) { + (ListingState::Locking, Some(CONFIRMED_STATUS), Some(h)) => { + Some(params_net.blocks_until_finalize(h, tip)) + } + _ => None, + }; + out.push(ListingNameRow { + name, + lock_pubkey_hex: pubkey, + listing: ListingNameState { + listing_id: id, + state, + blocks_until_finalize, + }, + }); + } + Ok(out) } /// The status of the listing's lock TRANSFER draft, `None` when the listing @@ -1874,7 +2173,13 @@ pub fn link_shakedex_listing_abort( ); Ok(conn.execute( &sql, - params![draft_id, profile_id, name, transfer_txid, cancel_txid], + params![ + draft_id, + profile_id, + name, + listing_txid(transfer_txid), + listing_txid(cancel_txid) + ], )?) } @@ -1913,7 +2218,7 @@ pub fn list_shakedex_listings_before_lock( /// the Cancel transfer, or a Finalizing one whose FINALIZE is dead, moves /// ([`ends_before_lock_sql`]). Returns how many rows changed (0 or 1). pub fn abort_shakedex_listing(conn: &rusqlite::Connection, id: &str) -> Result { - end_listing_before_lock(conn, id, ListingState::Aborted) + end_listing_before_lock(conn, id, ListingWrite::Abort) } /// hsd reports no live state for the name (`getnameinfo` with `info: null`) @@ -1922,39 +2227,48 @@ pub fn abort_shakedex_listing(conn: &rusqlite::Connection, id: &str) -> Result Result { - end_listing_before_lock(conn, id, ListingState::Expired) + end_listing_before_lock(conn, id, ListingWrite::ExpireBeforeLock) } fn end_listing_before_lock( conn: &rusqlite::Connection, id: &str, - to: ListingState, + w: ListingWrite, ) -> Result { let sql = format!( "UPDATE shakedex_listings SET state = ?2, lock_txid = NULL, lock_vout = NULL, {}, updated_at = datetime('now') WHERE id = ?1 AND {}", DROP_DEAD_FINALIZE_SET, - ends_before_lock_sql() + ends_before_lock_sql(w) ); - Ok(conn.execute(&sql, params![id, to])?) + Ok(conn.execute(&sql, params![id, w.target()])?) } /// The listings the chain may still end as before the FINALIZE into the /// lock (R19, [`abort_shakedex_listing`], [`expire_shakedex_listing`], -/// [`adopt_lock_finalized_elsewhere`]): those whose abort is still the -/// Cancel transfer ([`ListingState::CANCEL_ABORTABLE`]), and a Finalizing one -/// whose FINALIZE draft is dead (not [`draft_alive`], or its row gone): that -/// FINALIZE can no longer be mined, so whatever spent the lock TRANSFER was -/// something else. An SQL condition on `shakedex_listings`. -fn ends_before_lock_sql() -> String { +/// [`adopt_lock_finalized_elsewhere`]): `w`'s source states +/// ([`ListingWrite::from`]), a Finalizing one only while its FINALIZE draft +/// is dead (not [`draft_alive`], or its row gone): that FINALIZE can no +/// longer be mined, so whatever spent the lock TRANSFER was something else. +/// An SQL condition on `shakedex_listings`. +fn ends_before_lock_sql(w: ListingWrite) -> String { + let finalizing = ListingState::Finalizing; + let others = w.from().iter().filter(|s| **s != finalizing); + let dead_finalize = if w.from().contains(&finalizing) { + format!( + " OR (state = '{}' AND NOT EXISTS ( + SELECT 1 FROM wallet_tx_drafts d + WHERE d.id = shakedex_listings.lock_finalize_draft_id AND d.status IN {}))", + finalizing.as_str(), + alive_sql() + ) + } else { + String::new() + }; format!( - "(state IN {} OR (state = '{}' AND NOT EXISTS ( - SELECT 1 FROM wallet_tx_drafts d - WHERE d.id = shakedex_listings.lock_finalize_draft_id AND d.status IN {})))", - ListingState::cancel_abortable_sql(), - ListingState::Finalizing.as_str(), - alive_sql() + "(state IN {}{dead_finalize})", + sql_list(others.map(|s| s.as_str())) ) } @@ -1970,25 +2284,14 @@ const DROP_DEAD_FINALIZE_SET: &str = "lock_finalize_draft_id = NULL, steps_json /// listing stays the open one, and this one stays Aborted. Returns how many /// rows changed (0 or 1). pub fn unabort_shakedex_listing(conn: &rusqlite::Connection, id: &str) -> Result { - let [t0, t1, t2, t3] = ListingState::TERMINAL; - Ok(conn.execute( + let w = ListingWrite::Unabort; + let sql = format!( "UPDATE shakedex_listings SET state = ?2, updated_at = datetime('now') - WHERE id = ?1 AND state = ?3 - AND NOT EXISTS ( - SELECT 1 FROM shakedex_listings o - WHERE o.wallet_profile_id = shakedex_listings.wallet_profile_id - AND o.name = shakedex_listings.name AND o.id <> shakedex_listings.id - AND o.state NOT IN (?4, ?5, ?6, ?7))", - params![ - id, - ListingState::Locking, - ListingState::Aborted, - t0, - t1, - t2, - t3 - ], - )?) + WHERE id = ?1 AND {} AND {}", + w.source_sql(), + no_other_open_listing_sql() + ); + Ok(conn.execute(&sql, params![id, w.target()])?) } /// The columns a Finalizing listing loses when its FINALIZE never landed: @@ -2000,7 +2303,7 @@ fn revert_to_ready_set() -> String { "state = '{}', lock_finalize_draft_id = NULL, lock_txid = NULL, lock_vout = NULL, \ steps_json = '[]', listing_file_json = NULL, expires_at = NULL, \ updated_at = datetime('now')", - ListingState::ReadyToFinalize.as_str() + ListingWrite::RevertToReady.target().as_str() ) } @@ -2028,21 +2331,25 @@ pub fn mark_listing_finalizing_in_tx( ) -> Result { let expires_at = i64::try_from(f.expires_at) .map_err(|_| AppError::Other("listing expiry out of range".into()))?; - Ok(tx.execute( + let w = ListingWrite::Finalize; + let sql = format!( "UPDATE shakedex_listings - SET state = ?2, lock_finalize_draft_id = ?3, lock_txid = ?4, lock_vout = ?9, + SET state = ?2, lock_finalize_draft_id = ?3, lock_txid = ?4, lock_vout = ?8, steps_json = ?5, listing_file_json = ?6, expires_at = ?7, updated_at = datetime('now') - WHERE id = ?1 AND state = ?8", + WHERE id = ?1 AND {}", + w.source_sql() + ); + Ok(tx.execute( + &sql, params![ id, - ListingState::Finalizing, + w.target(), f.finalize_draft_id, - f.lock_txid, + listing_txid(f.lock_txid), f.steps_json, f.listing_file_json, expires_at, - ListingState::ReadyToFinalize, i64::from(f.lock_vout) ], )?) @@ -2054,36 +2361,28 @@ pub fn mark_listing_finalizing_in_tx( pub fn revert_listing_to_ready(conn: &rusqlite::Connection, id: &str) -> Result { Ok(conn.execute( &format!( - "UPDATE shakedex_listings SET {} WHERE id = ?1 AND state = ?2", - revert_to_ready_set() + "UPDATE shakedex_listings SET {} WHERE id = ?1 AND {}", + revert_to_ready_set(), + ListingWrite::RevertToReady.source_sql() ), - params![id, ListingState::Finalizing], + params![id], )?) } -/// `from` to `to` for listing `id`, only while it is in `from`. Returns how -/// many rows changed (0 or 1). -fn move_listing( - conn: &rusqlite::Connection, - id: &str, - from: ListingState, - to: ListingState, -) -> Result { - Ok(conn.execute( - "UPDATE shakedex_listings SET state = ?3, updated_at = datetime('now') - WHERE id = ?1 AND state = ?2", - params![id, from, to], - )?) +/// Write `w`, a state change alone, on listing `id`, only while it is in +/// one of `w`'s source states. Returns how many rows changed (0 or 1). +fn move_listing(conn: &rusqlite::Connection, id: &str, w: ListingWrite) -> Result { + let sql = format!( + "UPDATE shakedex_listings SET state = ?2, updated_at = datetime('now') + WHERE id = ?1 AND {}", + w.source_sql() + ); + Ok(conn.execute(&sql, params![id, w.target()])?) } /// The lockup is over (R19): Locking to ReadyToFinalize. pub fn mark_listing_ready(conn: &rusqlite::Connection, id: &str) -> Result { - move_listing( - conn, - id, - ListingState::Locking, - ListingState::ReadyToFinalize, - ) + move_listing(conn, id, ListingWrite::Ready) } /// A reorg undid the lock TRANSFER's lockup: ReadyToFinalize to Locking. @@ -2091,17 +2390,12 @@ pub fn mark_listing_locking_again( conn: &rusqlite::Connection, id: &str, ) -> Result { - move_listing( - conn, - id, - ListingState::ReadyToFinalize, - ListingState::Locking, - ) + move_listing(conn, id, ListingWrite::LockingAgain) } /// The FINALIZE into the lock is mined: Finalizing to Listed. pub fn mark_listing_listed(conn: &rusqlite::Connection, id: &str) -> Result { - move_listing(conn, id, ListingState::Finalizing, ListingState::Listed) + move_listing(conn, id, ListingWrite::Listed) } /// A reorg took the FINALIZE out of the chain: Listed to Finalizing. @@ -2109,7 +2403,7 @@ pub fn mark_listing_finalizing_again( conn: &rusqlite::Connection, id: &str, ) -> Result { - move_listing(conn, id, ListingState::Listed, ListingState::Finalizing) + move_listing(conn, id, ListingWrite::FinalizingAgain) } /// R19 (coordinator (b)): the name was finalized into this listing's lock by @@ -2130,32 +2424,304 @@ pub fn adopt_lock_finalized_elsewhere( abort_txid = NULL, {}, updated_at = datetime('now') WHERE id = ?1 AND {}", DROP_DEAD_FINALIZE_SET, - ends_before_lock_sql() + ends_before_lock_sql(ListingWrite::AdoptElsewhere) ); Ok(conn.execute( &sql, - params![id, ListingState::Restored, lock_txid, lock_vout], + params![ + id, + ListingWrite::AdoptElsewhere.target(), + listing_txid(lock_txid), + lock_vout + ], )?) } -/// Listings whose FINALIZE into the lock is built (Finalizing) or mined -/// (Listed): the chain decides between the two, or sends a Finalizing one -/// back to ReadyToFinalize. -pub fn list_shakedex_listings_finalizing( +/// The listings the after-lock job reads (R19, R22): +/// [`ListingState::AFTER_LOCK_JOB`], Sold only within the last +/// `sold_recheck_days` (a reorg can still take the purchase away). +pub fn list_shakedex_listings_after_lock( conn: &rusqlite::Connection, profile_id: &str, + sold_recheck_days: u32, ) -> Result, AppError> { + let open = sql_list( + ListingState::AFTER_LOCK_JOB + .iter() + .filter(|s| **s != ListingState::Sold) + .map(|s| s.as_str()), + ); let sql = format!( "SELECT {SHAKEDEX_LISTING_COLS} FROM shakedex_listings - WHERE wallet_profile_id = ?1 AND state IN {} - ORDER BY created_at, id", - sql_list(ListingState::FINALIZE_JOB.iter().map(|s| s.as_str())) + WHERE wallet_profile_id = ?1 + AND (state IN {open} OR (state = ?2 AND updated_at >= datetime('now', ?3))) + ORDER BY created_at, id" ); let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(params![profile_id], row_to_shakedex_listing)?; + let rows = stmt.query_map( + params![ + profile_id, + ListingState::Sold, + format!("-{sold_recheck_days} days") + ], + row_to_shakedex_listing, + )?; Ok(rows.collect::, _>>()?) } +/// R22: a purchase of the lock coin `lock` is in the mempool as +/// `purchase_txid`. Only from [`ListingWrite::SalePending`]'s sources, and only when +/// `lock` is the listing's stored lock outpoint: a listing without one is +/// never moved (only [`sell_listing_through_proven_lock`] gives a listing an +/// outpoint on sale), and a `lock` that differs from it is another lock's +/// purchase. Returns how many rows changed. +pub fn mark_listing_sale_pending( + conn: &rusqlite::Connection, + id: &str, + purchase_txid: &str, + lock: (&str, u32), +) -> Result { + sale_write(conn, id, ListingWrite::SalePending, purchase_txid, lock) +} + +/// R22: the lock coin `lock` was bought by the mined `purchase_txid`. Only +/// from [`ListingWrite::Sell`]'s sources, under the same stored-outpoint rule as +/// [`mark_listing_sale_pending`]. Returns how many rows changed. +pub fn sell_shakedex_listing( + conn: &rusqlite::Connection, + id: &str, + purchase_txid: &str, + lock: (&str, u32), +) -> Result { + sale_write(conn, id, ListingWrite::Sell, purchase_txid, lock) +} + +fn sale_write( + conn: &rusqlite::Connection, + id: &str, + w: ListingWrite, + purchase_txid: &str, + lock: (&str, u32), +) -> Result { + let sql = format!( + "UPDATE shakedex_listings + SET state = ?2, sold_txid = ?3, updated_at = datetime('now') + WHERE id = ?1 AND {} AND lock_txid = ?4 AND lock_vout = ?5", + w.source_sql() + ); + Ok(conn.execute( + &sql, + params![ + id, + w.target(), + listing_txid(purchase_txid), + listing_txid(lock.0), + i64::from(lock.1) + ], + )?) +} + +/// R22: the lock coin `lock`, which the job proved to be this listing's +/// (a FINALIZE of the name at its lock address spending its lock TRANSFER, +/// `shakedex_jobs::finalize_into_lock`), was bought by the mined +/// `purchase_txid`. Only from [`ListingWrite::ProvenLockSale`]'s sources and +/// only while the listing has no lock outpoint; `lock` becomes it. Returns +/// how many rows changed. +pub fn sell_listing_through_proven_lock( + conn: &rusqlite::Connection, + id: &str, + purchase_txid: &str, + lock: (&str, u32), +) -> Result { + let sql = format!( + "UPDATE shakedex_listings + SET state = ?2, sold_txid = ?3, lock_txid = ?4, lock_vout = ?5, + updated_at = datetime('now') + WHERE id = ?1 AND {} AND lock_txid IS NULL AND lock_vout IS NULL", + ListingWrite::ProvenLockSale.source_sql() + ); + Ok(conn.execute( + &sql, + params![ + id, + ListingWrite::ProvenLockSale.target(), + listing_txid(purchase_txid), + listing_txid(lock.0), + i64::from(lock.1) + ], + )?) +} + +/// R22, a reorg under a Sold listing: its purchase `purchase_txid` is back in +/// the mempool (`to` SalePending, hsd's `mempool._removeBlock`), or another +/// purchase of the same lock coin was mined instead (`to` Sold, the sale's +/// txid replaced). Only from Sold ([`ListingWrite::Resell`]; Sold is a +/// source for no other write) and +/// only for the listing's own lock outpoint `lock` (a Sold row always has +/// one). SalePending is an open listing again, so it is refused while +/// another listing of the name is open (`idx_shakedex_listings_open_name` +/// allows one). Returns how many rows changed (0 or 1). +pub fn resell_sold_listing( + conn: &rusqlite::Connection, + id: &str, + to: ListingState, + purchase_txid: &str, + lock: (&str, u32), +) -> Result { + let w = ListingWrite::Resell; + w.check_to(to)?; + let sql = format!( + "UPDATE shakedex_listings SET state = ?2, sold_txid = ?3, updated_at = datetime('now') + WHERE id = ?1 AND {} AND lock_txid = ?4 AND lock_vout = ?5 + AND (?2 = state OR {})", + w.source_sql(), + no_other_open_listing_sql() + ); + Ok(conn.execute( + &sql, + params![ + id, + to, + listing_txid(purchase_txid), + listing_txid(lock.0), + i64::from(lock.1) + ], + )?) +} + +/// R22, a reorg: the purchase is no longer in the chain, so a SalePending or +/// Sold listing goes back to `to` (Listed, Finalizing or Restored) and +/// forgets the purchase — unless another listing of the name is open by now +/// (`idx_shakedex_listings_open_name` allows one), which stays the open one. +/// Going back to Listed needs the listing file the steps are in. The +/// re-check window is not applied here: the caller got the row from +/// [`list_shakedex_listings_after_lock`], which applies it. Returns how many +/// rows changed (0 or 1). +pub fn unsell_shakedex_listing( + conn: &rusqlite::Connection, + id: &str, + to: ListingState, +) -> Result { + let w = ListingWrite::Unsell; + w.check_to(to)?; + let sql = format!( + "UPDATE shakedex_listings SET state = ?2, sold_txid = NULL, updated_at = datetime('now') + WHERE id = ?1 AND {} AND {} + AND (?2 <> ?3 OR listing_file_json IS NOT NULL)", + w.source_sql(), + no_other_open_listing_sql() + ); + Ok(conn.execute(&sql, params![id, to, ListingState::Listed])?) +} + +/// The name expired, or expired and was opened again, under a locked +/// listing ([`ListingWrite::ExpireLocked`]'s sources): Expired, keeping its lock +/// outpoint. Returns how many rows changed. +pub fn expire_locked_listing(conn: &rusqlite::Connection, id: &str) -> Result { + move_listing(conn, id, ListingWrite::ExpireLocked) +} + +/// A reorg took the FINALIZE a Restored lock was adopted from out of every +/// block and mempool (its lock TRANSFER is a coin again): Locking, without +/// the outpoint; the before-lock job takes it from there. Only a Restored +/// lock whose lock TRANSFER is known (one adopted from this device's +/// listing); a lock restored by name has none. Returns how many rows changed. +pub fn unadopt_restored_lock(conn: &rusqlite::Connection, id: &str) -> Result { + let w = ListingWrite::Unadopt; + let sql = format!( + "UPDATE shakedex_listings + SET state = ?2, lock_txid = NULL, lock_vout = NULL, updated_at = datetime('now') + WHERE id = ?1 AND {} AND lock_transfer_txid IS NOT NULL", + w.source_sql() + ); + Ok(conn.execute(&sql, params![id, w.target()])?) +} + +/// What a Restored lock gains from its own listing file (R32). +pub struct UpgradedListing<'a> { + pub mode: ListingMode, + pub payment_address: &'a str, + pub steps_json: &'a str, + pub listing_file_json: &'a str, + pub expires_at: Option, +} + +/// R32: a Restored lock tracking `(lock_txid, lock_vout)` with the lock key +/// `lock_pubkey_hex` becomes Listed with its file's details. Returns how many +/// rows changed (0 when the listing is no longer that Restored lock). The +/// caller must also check that the file's payment address is one of our +/// derived addresses: this write does not. +pub fn upgrade_restored_lock( + conn: &rusqlite::Connection, + id: &str, + lock_txid: &str, + lock_vout: u32, + lock_pubkey_hex: &str, + u: &UpgradedListing, +) -> Result { + let w = ListingWrite::Upgrade; + let sql = format!( + "UPDATE shakedex_listings + SET state = ?2, mode = ?3, payment_address = ?4, steps_json = ?5, + listing_file_json = ?6, expires_at = ?7, updated_at = datetime('now') + WHERE id = ?1 AND {} AND lock_txid = ?8 AND lock_vout = ?9 + AND lock_pubkey_hex = ?10", + w.source_sql() + ); + Ok(conn.execute( + &sql, + params![ + id, + w.target(), + u.mode, + u.payment_address, + u.steps_json, + u.listing_file_json, + u.expires_at, + listing_txid(lock_txid), + i64::from(lock_vout), + lock_pubkey_hex + ], + )?) +} + +/// Every transaction with a coin of ours at `address` the wallet's sync has +/// recorded, spent or not, with the height the coin was last seen at (-1 in +/// the mempool), most recently mined first. R22 looks for the purchase among +/// them: the purchase pays our listing's payment address. A row may come from +/// a transaction no longer in any block or mempool (sync's `'spent'` sentinel, +/// a stale height), so the caller must find the transaction on chain before +/// it counts as payment. +pub fn own_coins_at( + conn: &rusqlite::Connection, + profile_id: &str, + address: &str, +) -> Result)>, AppError> { + let mut stmt = conn.prepare( + "SELECT txid, MAX(height) FROM tracked_utxos + WHERE wallet_profile_id = ?1 AND address = ?2 + GROUP BY txid ORDER BY MAX(height) DESC, txid", + )?; + let rows = stmt.query_map(params![profile_id, address], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.collect::, _>>()?) +} + +/// Whether the wallet has recorded a coin of ours at `address` in +/// transaction `txid`, spent or not. +pub fn own_coin_in_tx( + conn: &rusqlite::Connection, + profile_id: &str, + txid: &str, + address: &str, +) -> Result { + Ok(conn.query_row( + "SELECT EXISTS(SELECT 1 FROM tracked_utxos + WHERE wallet_profile_id = ?1 AND txid = ?2 AND address = ?3)", + params![profile_id, txid, address], + |r| r.get(0), + )?) +} + /// `(profile, name, lock transfer txid)` of every listing that waits for /// Finalize & sign, across profiles (the `listing_ready` reminder): /// ReadyToFinalize, or Finalizing with its FINALIZE draft not sent yet diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e0a062..9b81577 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -704,6 +704,8 @@ pub fn run() { commands::shakedex::shakedex_build_lock_draft, commands::shakedex::shakedex_finalize_and_sign, commands::shakedex::shakedex_export_listing_file, + commands::shakedex::shakedex_restore_lock, + commands::shakedex::shakedex_import_own_listing_file, commands::names::get_name_action_capabilities, commands::names::get_names_action_capabilities, commands::bids::recover_bid_commitment, diff --git a/src-tauri/src/noncustodial/shakedex/listing_file.rs b/src-tauri/src/noncustodial/shakedex/listing_file.rs index 53ddf1a..111c9de 100644 --- a/src-tauri/src/noncustodial/shakedex/listing_file.rs +++ b/src-tauri/src/noncustodial/shakedex/listing_file.rs @@ -236,6 +236,13 @@ impl ListingFile { }) } + /// Whether the file as written names a fee address (`feeAddr` present + /// and not null), whatever its fees: [`Self::fee_addr`] is `None` beside + /// zero fees, but the file still says it. + pub fn names_a_fee_address(&self) -> bool { + self.as_written.get("feeAddr").is_some_and(|v| !v.is_null()) + } + pub fn to_json(&self) -> Result { serde_json::to_string(&self.as_written).map_err(AppError::from) } diff --git a/src-tauri/src/noncustodial/shakedex/purchase.rs b/src-tauri/src/noncustodial/shakedex/purchase.rs index ce72518..3d9c952 100644 --- a/src-tauri/src/noncustodial/shakedex/purchase.rs +++ b/src-tauri/src/noncustodial/shakedex/purchase.rs @@ -309,7 +309,12 @@ pub fn transfer_commits_to( /// little-endian bytes in hex. `None` for anything else, which is not hsd's /// reply and proves nothing. pub fn covenant_name_height(cov: &NodeCovenant) -> Option { - cov.items + items_name_height(&cov.items) +} + +/// [`covenant_name_height`] of a covenant's items. +pub fn items_name_height(items: &[String]) -> Option { + items .get(1) .and_then(|h| hex::decode(h).ok()) .and_then(|b| <[u8; 4]>::try_from(b).ok()) diff --git a/src-tauri/src/noncustodial/shakedex/sell.rs b/src-tauri/src/noncustodial/shakedex/sell.rs index e402b7d..20b2f38 100644 --- a/src-tauri/src/noncustodial/shakedex/sell.rs +++ b/src-tauri/src/noncustodial/shakedex/sell.rs @@ -2,20 +2,24 @@ //! key (R17), the lock's self-check before a name enters it (R18), the Buy //! Now lock time (R19) and the FINALIZE into the lock. +use std::collections::HashSet; + use crate::error::AppError; use crate::noncustodial::actions::{DraftPlan, PlanResult}; +use crate::noncustodial::address; use crate::noncustodial::covenants; use crate::noncustodial::names; use crate::noncustodial::network::{NameParams, Network}; +use crate::noncustodial::rpc; use crate::noncustodial::send::{SpendableCoin, DUST_THRESHOLD}; use crate::noncustodial::shakedex::funding::{cov_out, fund, own_input}; use crate::noncustodial::shakedex::lock_key::LockKey; use crate::noncustodial::shakedex::purchase::MAX_MONEY; use crate::noncustodial::shakedex::script; use crate::noncustodial::shakedex::template::{ - secs_until_valid, valid_from_mtp, verify_step_signature, StepTemplate, + secs_until_valid, valid_from_mtp, verify_step_signature, StepTemplate, STEP_SIGHASH, }; -use crate::noncustodial::sync::COV_FINALIZE; +use crate::noncustodial::sync::{COV_FINALIZE, COV_NONE, COV_TRANSFER}; use crate::noncustodial::tx::{Covenant, OutputAddress}; use crate::noncustodial::types::doos_to_hns_string; @@ -194,7 +198,7 @@ pub fn lock_transfer_owns_name( revoked: u64, lock_transfer_txid: &str, ) -> bool { - revoked == 0 && owner_index == 0 && owner_hash.eq_ignore_ascii_case(lock_transfer_txid) + revoked == 0 && owner_index == 0 && owner_hash == lock_transfer_txid } /// The block of the name's TRANSFER, hsd's `info.transfer` in a @@ -437,6 +441,565 @@ pub fn expires_before_finalize(name: &str, expiry_end: i64) -> AppError { )) } +/// A transaction as hsd shows it, reduced to what R22 reads. Built from +/// either of hsd's shapes ([`spend_view_from_rest`], [`spend_view_from_block`]), +/// so the rule ([`purchase_in`]) is one whichever way it was found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpendView { + /// The block it is in; `None` in the mempool. + pub height: Option, + /// Each input's prevout `(txid, index)`. + pub inputs: Vec<(String, u32)>, + /// Each input's witness items, hex as hsd sends them (`Witness.getJSON`: + /// REST `inputs[].witness`, `getblock` `vin[].txinwitness`); `None` when + /// the reply leaves the field out, which only a verdict that reads it + /// refuses. + pub witnesses: Vec>>, + pub outputs: Vec, +} + +/// One output of a [`SpendView`]; `address` is the bech32 string, as both of +/// hsd's shapes send it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpendOutput { + pub address: String, + pub covenant_type: u8, + /// The covenant's items, hex as hsd sends them. + pub items: Vec, +} + +fn not_hsds(what: &str) -> AppError { + AppError::Rpc(format!("node sent a transaction without {what}")) +} + +fn output_of( + address: Option<&str>, + cov: Option<&serde_json::Value>, +) -> Result { + let address = address.ok_or_else(|| not_hsds("an output's address"))?; + let cov = cov.ok_or_else(|| not_hsds("an output's covenant"))?; + let covenant_type = cov + .get("type") + .and_then(|t| t.as_u64()) + .and_then(|t| u8::try_from(t).ok()) + .ok_or_else(|| not_hsds("a covenant type"))?; + let items = cov + .get("items") + .and_then(|i| i.as_array()) + .ok_or_else(|| not_hsds("covenant items"))? + .iter() + .map(|i| { + i.as_str() + .map(str::to_string) + .ok_or_else(|| not_hsds("a covenant item")) + }) + .collect::, _>>()?; + Ok(SpendOutput { + address: address.to_string(), + covenant_type, + items, + }) +} + +/// An input's witness as hsd sends it: an array of hex items; absent (or +/// null) is `None`; anything else is not hsd's answer. +fn witness_of(w: Option<&serde_json::Value>) -> Result>, AppError> { + let Some(w) = w.filter(|w| !w.is_null()) else { + return Ok(None); + }; + let items = w + .as_array() + .ok_or_else(|| not_hsds("a readable input witness"))?; + items + .iter() + .map(|i| { + i.as_str() + .filter(|h| h.len() % 2 == 0 && h.bytes().all(|b| b.is_ascii_hexdigit())) + .map(str::to_string) + .ok_or_else(|| not_hsds("a readable input witness item")) + }) + .collect::, _>>() + .map(Some) +} + +fn prevout_of( + hash: Option<&serde_json::Value>, + index: Option<&serde_json::Value>, +) -> Result<(String, u32), AppError> { + let hash = hash.and_then(|h| h.as_str()); + let index = index + .and_then(|x| x.as_u64()) + .and_then(|x| u32::try_from(x).ok()); + match (hash, index) { + (Some(h), Some(x)) => Ok((h.to_string(), x)), + _ => Err(not_hsds("an input's prevout")), + } +} + +/// [`SpendView`] from hsd's `GET /tx/:hash` (`TX.getJSON` and +/// `TXMeta.getJSON`): `height` (-1 in the mempool), `inputs[].prevout.{hash, +/// index}`, `outputs[].{address, covenant}`. A reply missing any of them is +/// not hsd's whole answer. +pub fn spend_view_from_rest(tx: &serde_json::Value) -> Result { + let height = rpc::mined_height(tx.get("height").and_then(|h| h.as_i64()), || { + "the transaction's height".into() + })?; + let raw_inputs = tx + .get("inputs") + .and_then(|i| i.as_array()) + .ok_or_else(|| not_hsds("its inputs"))?; + let inputs = raw_inputs + .iter() + .map(|i| { + let p = i.get("prevout"); + prevout_of( + p.and_then(|p| p.get("hash")), + p.and_then(|p| p.get("index")), + ) + }) + .collect::, _>>()?; + let witnesses = raw_inputs + .iter() + .map(|i| witness_of(i.get("witness"))) + .collect::, _>>()?; + let outputs = tx + .get("outputs") + .and_then(|o| o.as_array()) + .ok_or_else(|| not_hsds("its outputs"))? + .iter() + .map(|o| output_of(o.get("address").and_then(|a| a.as_str()), o.get("covenant"))) + .collect::, _>>()?; + Ok(SpendView { + height, + inputs, + witnesses, + outputs, + }) +} + +/// [`SpendView`] of transaction `txid` in a block from `getblock true +/// true` (`rpc.js` `txToJSON`: `vin[].{txid, vout}`, `vout[].address.string`, +/// `vout[].covenant`), `None` when the block does not hold it. Needs no +/// transaction index. A block or transaction missing a field is an error. +pub fn spend_view_from_block( + block: &serde_json::Value, + txid: &str, +) -> Result, AppError> { + let height = block + .get("height") + .and_then(|h| h.as_i64()) + .filter(|h| *h >= 0) + .ok_or_else(|| not_hsds("its block's height"))?; + let txs = block + .get("tx") + .and_then(|t| t.as_array()) + .ok_or_else(|| not_hsds("a block's transactions"))?; + // Every entry must carry its txid: `getblock` without details sends bare + // txid strings, and an entry skipped for lacking one would answer "not in + // this block" for a purchase that is. + let mut found = None; + for t in txs { + let id = t + .get("txid") + .and_then(|h| h.as_str()) + .ok_or_else(|| not_hsds("a block transaction's txid"))?; + if found.is_none() && id == txid { + found = Some(t); + } + } + let Some(tx) = found else { + return Ok(None); + }; + let vin = tx + .get("vin") + .and_then(|i| i.as_array()) + .ok_or_else(|| not_hsds("its inputs"))?; + let inputs = vin + .iter() + .map(|i| prevout_of(i.get("txid"), i.get("vout"))) + .collect::, _>>()?; + let witnesses = vin + .iter() + .map(|i| witness_of(i.get("txinwitness"))) + .collect::, _>>()?; + let outputs = tx + .get("vout") + .and_then(|o| o.as_array()) + .ok_or_else(|| not_hsds("its outputs"))? + .iter() + .map(|o| { + output_of( + o.get("address") + .and_then(|a| a.get("string")) + .and_then(|s| s.as_str()), + o.get("covenant"), + ) + }) + .collect::, _>>()?; + Ok(Some(SpendView { + height: Some(height), + inputs, + witnesses, + outputs, + })) +} + +/// A coin's address and covenant as hsd reports them, from either of its +/// shapes: `GET /coin` ([`CoinAt::of_coin`]) or a transaction's output +/// ([`CoinAt::of_output`]). +#[derive(Debug, Clone, Copy)] +pub struct CoinAt<'a> { + pub address: &'a str, + pub covenant_type: u8, + /// The covenant's items, hex as hsd sends them. + pub items: &'a [String], +} + +impl<'a> CoinAt<'a> { + pub fn of_output(o: &'a SpendOutput) -> Self { + Self { + address: &o.address, + covenant_type: o.covenant_type, + items: &o.items, + } + } + + /// `None` for a coin without its address or covenant: not hsd's whole + /// answer, which the caller reports as such. + pub fn of_coin(c: &'a rpc::NodeCoin) -> Option { + let (address, cov) = (c.address.as_deref()?, c.covenant.as_ref()?); + Some(Self { + address, + covenant_type: cov.kind, + items: &cov.items, + }) + } +} + +/// One listing's lock: the lock address its key derives, and its name's +/// hash. Every lock coin of a key sits at that one address (ADR 0004), so +/// the address alone does not say whose a coin there is: the covenant's +/// name hash does. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListingLock { + pub address: String, + /// SHA3-256 of the listing's name, hex. + pub name_hash: String, +} + +impl ListingLock { + pub fn new(address: String, name: &str) -> Result { + Ok(Self { + address, + name_hash: hex::encode(names::hash_name(name)?), + }) + } + + /// Whether `coin` sits at this lock address. + pub fn is_at(&self, coin: CoinAt) -> bool { + coin.address == self.address + } + + /// Whether `coin` is a `covenant_type` covenant of this name at this + /// lock address, and, with `name_height`, commits to that name height + /// (item 1, [`purchase::items_name_height`]). The one rule every + /// verdict on "the name is in (or left through) this lock" reads. + pub fn holds(&self, coin: CoinAt, covenant_type: u8, name_height: Option) -> bool { + self.is_at(coin) + && coin.covenant_type == covenant_type + && coin + .items + .first() + .is_some_and(|h| h.eq_ignore_ascii_case(&self.name_hash)) + && name_height.is_none_or(|h| { + crate::noncustodial::shakedex::purchase::items_name_height(coin.items) == Some(h) + }) + } +} + +/// Whether a TRANSFER covenant's commitment (items 2–3: address version and +/// hash) is an address of ours. Ours are P2WPKH (version 0, 20 bytes); any +/// other commitment is not ours. Items hsd did not send whole are an error, +/// not a "no": a "no" can end a listing as Sold. +pub fn commitment_is_ours( + items: &[String], + network: Network, + own: &HashSet, +) -> Result { + let [_, _, version, hash, ..] = items else { + return Err(AppError::Rpc( + "node did not report the transfer covenant's address".into(), + )); + }; + let version = hex::decode(version).ok().filter(|v| v.len() == 1); + let hash = hex::decode(hash).ok(); + let (Some(version), Some(hash)) = (version, hash) else { + return Err(AppError::Rpc( + "node reported an unreadable transfer commitment".into(), + )); + }; + let Ok(hash20) = <[u8; 20]>::try_from(hash.as_slice()) else { + return Ok(false); + }; + if version[0] != 0 { + return Ok(false); + } + Ok(own.contains(&address::encode_p2wpkh(network, &hash20)?)) +} + +/// What a purchase of one listing's lock coin looks like (R22). +pub struct PurchaseOf<'a> { + pub network: Network, + /// The listing's lock coin, when it knows it (a dead FINALIZE's listing + /// back at ReadyToFinalize does not). + pub lock: Option<(&'a str, u32)>, + /// The listing's lock address and name. + pub at: &'a ListingLock, + pub payment_address: &'a str, + /// Every derived address of the profile. + pub own: &'a HashSet, +} + +/// R22: whether `tx` bought the lock coin: one of its outputs pays the +/// listing's payment address (covenant NONE), and the output at some input's +/// index is a TRANSFER of the listing's name at its lock address (hsd links +/// a TRANSFER to the input at its index, and keeps the lock address on a +/// FINALIZE→TRANSFER) committing to an address not ours, that input spending +/// the listing's lock coin when it is known. Returns the coin that input +/// spends. A TRANSFER back to an address of ours (our cancel, or a gift back +/// to us) is not a sale under R22. +pub fn purchase_in(tx: &SpendView, p: &PurchaseOf) -> Result, AppError> { + if !tx + .outputs + .iter() + .any(|o| o.address == p.payment_address && o.covenant_type == COV_NONE) + { + return Ok(None); + } + for (k, prevout) in tx.inputs.iter().enumerate() { + let Some(out) = tx.outputs.get(k) else { + continue; + }; + if !p.at.holds(CoinAt::of_output(out), COV_TRANSFER, None) { + continue; + } + if let Some((txid, vout)) = p.lock { + if !(prevout.0 == txid && prevout.1 == vout) { + continue; + } + } + if commitment_is_ours(&out.items, p.network, p.own)? { + return Ok(None); + } + return Ok(Some(prevout.clone())); + } + Ok(None) +} + +/// R22 for a lock restored by name (R32), which knows no payment address: +/// whether `tx`, mined in a block, is a price step's purchase of the lock +/// coin `lock`: input `k` spends it with the witness `[signature, lock +/// script]` whose signature's last byte, the sighash type, is `0x84` +/// (`ANYONECANPAY | SINGLEREVERSE`, [`STEP_SIGHASH`]: only a price step is +/// signed so; our cancel is `0x83`), output `k` is a TRANSFER of the +/// listing's name at its lock address committing to an address not ours +/// (a cheap extra check: the derived addresses after a restore are not +/// complete, spec §5), and output `len - 1 - k`, the output `0x84` commits +/// to, is a plain payment (covenant NONE). The sale was paid to that step's +/// address, which this device does not know. A lock input whose witness the +/// reply leaves out is an error: no verdict either way. +pub fn sale_out_of_restored_lock( + tx: &SpendView, + k: u32, + lock: (&str, u32), + at: &ListingLock, + network: Network, + own: &HashSet, +) -> Result { + let k = k as usize; + let (Some(input), Some(out)) = (tx.inputs.get(k), tx.outputs.get(k)) else { + return Ok(false); + }; + if tx.height.is_none() + || !(input.0 == lock.0 && input.1 == lock.1) + || !at.holds(CoinAt::of_output(out), COV_TRANSFER, None) + { + return Ok(false); + } + let witness = tx + .witnesses + .get(k) + .cloned() + .flatten() + .ok_or_else(|| not_hsds("the lock input's witness"))?; + let [signature, _lock_script] = witness.as_slice() else { + return Ok(false); + }; + let sighash = hex::decode(signature) + .ok() + .and_then(|b| b.last().copied()) + .ok_or_else(|| not_hsds("a readable lock input signature"))?; + if u32::from(sighash) != STEP_SIGHASH { + return Ok(false); + } + let paid = tx + .outputs + .len() + .checked_sub(1 + k) + .and_then(|p| tx.outputs.get(p)) + .is_some_and(|p| p.covenant_type == COV_NONE); + if !paid { + return Ok(false); + } + Ok(!commitment_is_ours(&out.items, network, own)?) +} + +/// R32's refusals, in the backend's words (T7's UI shows them as sent). +pub const RESTORE_NOT_AT_OUR_LOCK: &str = + "the name is not in this wallet's lock for it: its owner coin is elsewhere"; +pub const RESTORE_STILL_LOCKING: &str = "the name is still on its way into the lock: a \ + TRANSFER committing to it is mined but the FINALIZE into it is not; the way out is Cancel \ + transfer, from the wallet that holds the name"; +pub const RESTORE_ALREADY_TRANSFER: &str = "the coin in the lock is already a TRANSFER, not a \ + FINALIZE: a cancel awaiting its finalize, or a mined purchase; there is no lock to restore"; +pub const RESTORE_LEFTOVER: &str = "the coin in the lock is left over from an earlier \ + registration of the name: there is no lock to restore"; +pub const RESTORE_REVOKED: &str = "the name was revoked: there is no lock to restore"; +pub const RESTORE_SPENT_IN_MEMPOOL: &str = + "the name's owner coin is being spent in the node's mempool: restore it once that is mined"; + +/// The name's owner outpoint and name height, as hsd's `getnameinfo` reports +/// them, that a restore by name (R32) judges. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestoreOwner { + pub txid: String, + pub vout: u32, + pub name_height: u32, +} + +/// R32: the owner outpoint and name height of `name` from `getnameinfo`. +/// `info: null` is an expired name and a non-zero `info.revoked` a revoked +/// one (both refused); a reply without the owner, the height or `revoked`, +/// or with an owner hash that is not a txid, is "could not check"; hsd's null +/// owner (all zeros) is not in our lock. +pub fn restore_owner(reply: &serde_json::Value, name: &str) -> Result { + let missing = |what: &str| { + AppError::Rpc(format!( + "node did not report {what}: could not check '{name}'" + )) + }; + let info = match reply.get("info") { + None => return Err(missing("the name's info")), + Some(serde_json::Value::Null) => { + return Err(AppError::InvalidInput(format!( + "'{name}' has no on-chain state or has expired: there is no lock to restore" + ))) + } + Some(i) => i, + }; + let owner = info.get("owner"); + let txid = owner + .and_then(|o| o.get("hash")) + .and_then(|h| h.as_str()) + .filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit())) + .ok_or_else(|| missing("the name's owner"))?; + let vout = owner + .and_then(|o| o.get("index")) + .and_then(|i| i.as_u64()) + .and_then(|i| u32::try_from(i).ok()) + .ok_or_else(|| missing("the name's owner output"))?; + let name_height = info + .get("height") + .and_then(|h| h.as_u64()) + .and_then(|h| u32::try_from(h).ok()) + .ok_or_else(|| missing("the name's height"))?; + let revoked = info + .get("revoked") + .and_then(|r| r.as_u64()) + .ok_or_else(|| missing("whether the name was revoked"))?; + if revoked != 0 { + return Err(AppError::InvalidInput(RESTORE_REVOKED.into())); + } + if txid.bytes().all(|b| b == b'0') { + return Err(AppError::InvalidInput(RESTORE_NOT_AT_OUR_LOCK.into())); + } + Ok(RestoreOwner { + txid: txid.to_ascii_lowercase(), + vout, + name_height, + }) +} + +/// R32: whether `coin` (hsd's `GET /coin` for `owner`) is our lock for +/// `name`: a mined FINALIZE of the name at `lock_address` whose covenant +/// commits to the owner's name height. A TRANSFER elsewhere committing to +/// the lock is still Locking (at whichever address sent it: this one is not +/// compared with ours), a TRANSFER at the lock is a cancel or a purchase, +/// another height is a leftover, anything else is not our lock; each is +/// refused with its reason. A coin missing a field, or one +/// that is not the outpoint or the name asked about, is "could not check". +pub fn restore_verdict( + network: Network, + name: &str, + lock_address: &str, + owner: &RestoreOwner, + coin: &rpc::NodeCoin, +) -> Result<(), AppError> { + if !(coin.txid == owner.txid && coin.vout == owner.vout) { + return Err(AppError::Rpc(format!( + "node answered coin {}:{} for the owner coin {}:{}", + coin.txid, coin.vout, owner.txid, owner.vout + ))); + } + let Some(at) = CoinAt::of_coin(coin) else { + return Err(AppError::Rpc( + "node did not report the owner coin's address or covenant".into(), + )); + }; + if coin.mined_height()?.is_none() { + return Err(AppError::Rpc( + "node reported the name's owner coin in the mempool".into(), + )); + } + let lock = ListingLock::new(lock_address.to_string(), name)?; + if !at + .items + .first() + .is_some_and(|h| h.eq_ignore_ascii_case(&lock.name_hash)) + { + return Err(AppError::Rpc(format!( + "node reported an owner coin of '{name}' whose covenant is not of that name" + ))); + } + if lock.holds(at, COV_FINALIZE, Some(owner.name_height)) { + return Ok(()); + } + // Not our lock: say why. + if !lock.is_at(at) { + if at.covenant_type == COV_TRANSFER + && crate::noncustodial::shakedex::purchase::transfer_commits_to( + coin, + network, + lock_address, + )? + { + return Err(AppError::InvalidInput(RESTORE_STILL_LOCKING.into())); + } + return Err(AppError::InvalidInput(RESTORE_NOT_AT_OUR_LOCK.into())); + } + match at.covenant_type { + COV_FINALIZE => {} + COV_TRANSFER => return Err(AppError::InvalidInput(RESTORE_ALREADY_TRANSFER.into())), + _ => return Err(AppError::InvalidInput(RESTORE_NOT_AT_OUR_LOCK.into())), + } + match crate::noncustodial::shakedex::purchase::items_name_height(at.items) { + None => Err(AppError::Rpc( + "node did not report a readable name height in the lock coin".into(), + )), + Some(_) => Err(AppError::InvalidInput(RESTORE_LEFTOVER.into())), + } +} + #[cfg(test)] mod tests { use super::*; @@ -741,4 +1304,451 @@ mod tests { ); assert_eq!(serde_json::from_value::(j).unwrap(), s); } + + const LOCK: &str = "f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1"; + const BUY: &str = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1"; + + fn p2wpkh(byte: u8) -> String { + crate::noncustodial::address::encode_p2wpkh(Network::Regtest, &[byte; 20]).unwrap() + } + + fn lock_addr() -> String { + crate::noncustodial::address::encode_p2wsh(Network::Regtest, &[7; 32]).unwrap() + } + + fn nh() -> String { + hex::encode(names::hash_name("dexstate").unwrap()) + } + + /// The purchase as hsd's `GET /tx` sends it (`TX.getJSON`): input 0 our + /// lock coin, output 0 the TRANSFER at the lock committing to `to`, + /// output 1 change, output 2 the price to `pay`. + fn rest(height: i64, to_byte: u8, pay: &str) -> serde_json::Value { + let none = serde_json::json!({ "type": 0, "action": "NONE", "items": [] }); + serde_json::json!({ + "hash": BUY, "height": height, + "inputs": [ { "prevout": { "hash": LOCK, "index": 0 }, + "witness": [format!("{}84", "aa".repeat(64)), "76".repeat(40)] }, + { "prevout": { "hash": "aa".repeat(32), "index": 1 }, + "witness": [format!("{}01", "bb".repeat(64)), "02".repeat(33)] } ], + "outputs": [ + { "value": 1_000_000, "address": lock_addr(), + "covenant": { "type": 9, "action": "TRANSFER", + "items": [nh(), "32000000", "00", hex::encode([to_byte; 20])] } }, + { "value": 1, "address": p2wpkh(9), "covenant": none }, + { "value": 5_000_000, "address": pay, "covenant": none } + ], + "hex": "00" + }) + } + + /// The same purchase in a block as `getblock true true` sends it + /// (`rpc.js` `txToJSON`: `vin[].{txid, vout}`, `vout[].address.string`). + fn block(height: i64, pay: &str) -> serde_json::Value { + let tx = rest(height, 9, pay); + let vin: Vec<_> = tx["inputs"] + .as_array() + .unwrap() + .iter() + .map(|i| { + serde_json::json!({ "coinbase": false, "txid": i["prevout"]["hash"], + "vout": i["prevout"]["index"], "txinwitness": i["witness"], + "sequence": 4294967295u64, "link": 4294967295u64 }) + }) + .collect(); + let vout: Vec<_> = tx["outputs"] + .as_array() + .unwrap() + .iter() + .enumerate() + .map(|(n, o)| { + let (version, program) = crate::noncustodial::address::decode( + Network::Regtest, + o["address"].as_str().unwrap(), + ) + .unwrap(); + serde_json::json!({ "value": 0.0, "n": n, + "address": { "version": version, "hash": hex::encode(program), + "string": o["address"] }, + "covenant": o["covenant"] }) + }) + .collect(); + serde_json::json!({ "height": height, "tx": [ + { "txid": "cc".repeat(32), "vin": [], "vout": [] }, + { "txid": BUY, "vin": vin, "vout": vout } + ] }) + } + + #[test] + fn spend_views_read_hsds_rest_and_block_shapes() { + let pay = p2wpkh(5); + let from_rest = spend_view_from_rest(&rest(120, 9, &pay)).unwrap(); + let from_block = spend_view_from_block(&block(120, &pay), BUY) + .unwrap() + .unwrap(); + assert_eq!(from_rest, from_block); + assert_eq!(from_rest.height, Some(120)); + assert_eq!(from_rest.inputs[0], (LOCK.to_string(), 0)); + assert_eq!(from_rest.outputs[2].address, pay); + assert_eq!(from_rest.outputs[0].covenant_type, 9); + assert_eq!( + spend_view_from_rest(&rest(-1, 9, &pay)).unwrap().height, + None + ); + assert_eq!( + spend_view_from_block(&block(120, &pay), &"dd".repeat(32)).unwrap(), + None + ); + } + + #[test] + fn spend_view_refuses_a_reply_missing_a_field() { + let pay = p2wpkh(5); + let mut cases: Vec<(&str, serde_json::Value)> = Vec::new(); + for (what, path) in [ + ("height", "/height"), + ("inputs", "/inputs"), + ("prevout hash", "/inputs/0/prevout/hash"), + ("prevout index", "/inputs/0/prevout/index"), + ("outputs", "/outputs"), + ("address", "/outputs/2/address"), + ("covenant", "/outputs/2/covenant"), + ("covenant type", "/outputs/0/covenant/type"), + ("covenant items", "/outputs/0/covenant/items"), + ] { + let mut v = rest(120, 9, &pay); + let (parent, key) = path.rsplit_once('/').unwrap(); + v.pointer_mut(parent) + .unwrap() + .as_object_mut() + .unwrap() + .remove(key); + cases.push((what, v)); + } + for (what, v) in cases { + assert!(spend_view_from_rest(&v).is_err(), "{what}"); + } + // Present but malformed. + for (what, path, bad) in [ + ("height as text", "/height", serde_json::json!("120")), + ("height below -1", "/height", serde_json::json!(-2)), + ( + "negative index", + "/inputs/0/prevout/index", + serde_json::json!(-1), + ), + ( + "item not text", + "/outputs/0/covenant/items/0", + serde_json::json!(5), + ), + ( + "type too big", + "/outputs/0/covenant/type", + serde_json::json!(256), + ), + ] { + let mut v = rest(120, 9, &pay); + *v.pointer_mut(path).unwrap() = bad; + assert!(spend_view_from_rest(&v).is_err(), "{what}"); + } + let mut b = block(120, &pay); + b.as_object_mut().unwrap().remove("height"); + assert!(spend_view_from_block(&b, BUY).is_err(), "block height"); + // A transaction entry without a txid (or a bare txid string, as + // `getblock` sends them without details) cannot be searched: an error, + // never "not in this block". + let mut b = block(120, &pay); + b["tx"][0].as_object_mut().unwrap().remove("txid"); + assert!( + spend_view_from_block(&b, BUY).is_err(), + "entry without txid" + ); + let mut b = block(120, &pay); + b["tx"] = serde_json::json!(["cc".repeat(32), BUY]); + assert!( + spend_view_from_block(&b, BUY).is_err(), + "tx array of strings" + ); + let mut b = block(120, &pay); + b["tx"][0]["txid"] = serde_json::json!(5); + assert!(spend_view_from_block(&b, BUY).is_err(), "txid not text"); + for (what, path) in [ + ("block transactions", "/tx"), + ("vin", "/tx/1/vin"), + ("vin txid", "/tx/1/vin/0/txid"), + ("vin vout", "/tx/1/vin/0/vout"), + ("vout", "/tx/1/vout"), + ("output address", "/tx/1/vout/2/address"), + ("output address string", "/tx/1/vout/2/address/string"), + ("output covenant", "/tx/1/vout/2/covenant"), + ] { + let mut b = block(120, &pay); + let (parent, key) = path.rsplit_once('/').unwrap(); + let target = if parent.is_empty() { + &mut b + } else { + b.pointer_mut(parent).unwrap() + }; + target.as_object_mut().unwrap().remove(key); + assert!(spend_view_from_block(&b, BUY).is_err(), "{what}"); + } + } + + fn purchase_of<'a>( + own: &'a HashSet, + lock: Option<(&'a str, u32)>, + pay: &'a str, + at: &'a ListingLock, + ) -> PurchaseOf<'a> { + PurchaseOf { + network: Network::Regtest, + lock, + at, + payment_address: pay, + own, + } + } + + fn lock_of(address: &str, name_hash: &str) -> ListingLock { + ListingLock { + address: address.into(), + name_hash: name_hash.into(), + } + } + + /// A lock restored by name is sold by a mined TRANSFER out of its lock + /// coin committing to an address not ours, linked input k → output k; + /// any one of those facts missing is no sale. + #[test] + fn sale_out_of_restored_lock_needs_every_link() { + let own: HashSet = [p2wpkh(5)].into(); + let at = lock_of(&lock_addr(), &nh()); + let sold = |tx: &SpendView, k: u32, lock: (&str, u32)| { + sale_out_of_restored_lock(tx, k, lock, &at, Network::Regtest, &own).unwrap() + }; + let mined = spend_view_from_rest(&rest(120, 9, &p2wpkh(6))).unwrap(); + assert!(sold(&mined, 0, (LOCK, 0))); + let mempool = spend_view_from_rest(&rest(-1, 9, &p2wpkh(6))).unwrap(); + assert!(!sold(&mempool, 0, (LOCK, 0)), "in the mempool"); + let ours = spend_view_from_rest(&rest(120, 5, &p2wpkh(6))).unwrap(); + assert!(!sold(&ours, 0, (LOCK, 0)), "committing to ours"); + assert!(!sold(&mined, 0, (LOCK, 1)), "another lock coin"); + assert!(!sold(&mined, 0, (BUY, 0)), "another lock txid"); + assert!(!sold(&mined, 1, (LOCK, 0)), "output 1 is not the TRANSFER"); + assert!(!sold(&mined, 5, (LOCK, 0)), "no such input"); + let other = lock_of( + &lock_addr(), + &hex::encode(names::hash_name("other").unwrap()), + ); + assert!( + !sale_out_of_restored_lock(&mined, 0, (LOCK, 0), &other, Network::Regtest, &own) + .unwrap(), + "another name" + ); + // The same from the block hsd's `getblock` sends. + let in_block = spend_view_from_block(&block(120, &p2wpkh(6)), BUY) + .unwrap() + .unwrap(); + assert!(sold(&in_block, 0, (LOCK, 0)), "from the block"); + // The positive evidence: input 0's sighash type is a price step's. + let with_lock_witness = |w: serde_json::Value| { + let mut v = rest(120, 9, &p2wpkh(6)); + v["inputs"][0]["witness"] = w; + spend_view_from_rest(&v).unwrap() + }; + let sig = |h: &str| format!("{}{h}", "aa".repeat(64)); + let cancel = with_lock_witness(serde_json::json!([sig("83"), "76".repeat(40)])); + assert!(!sold(&cancel, 0, (LOCK, 0)), "0x83: a cancel, not a sale"); + let one_item = with_lock_witness(serde_json::json!([sig("84")])); + assert!(!sold(&one_item, 0, (LOCK, 0)), "not [signature, script]"); + let no_witness = with_lock_witness(serde_json::Value::Null); + assert!( + sale_out_of_restored_lock(&no_witness, 0, (LOCK, 0), &at, Network::Regtest, &own) + .is_err(), + "no witness: no verdict" + ); + let empty_sig = with_lock_witness(serde_json::json!(["", "76".repeat(40)])); + assert!( + sale_out_of_restored_lock(&empty_sig, 0, (LOCK, 0), &at, Network::Regtest, &own) + .is_err(), + "no sighash byte: no verdict" + ); + // Output len - 1 - k, the one 0x84 commits to, must be a payment. + let mut v = rest(120, 9, &p2wpkh(6)); + v["outputs"][2]["covenant"] = + serde_json::json!({ "type": 2, "action": "OPEN", "items": [] }); + let not_paid = spend_view_from_rest(&v).unwrap(); + assert!(!sold(¬_paid, 0, (LOCK, 0)), "no payment output"); + let mut v = rest(120, 9, &p2wpkh(6)); + v["outputs"].as_array_mut().unwrap().truncate(1); + let lone = spend_view_from_rest(&v).unwrap(); + assert!( + !sold(&lone, 0, (LOCK, 0)), + "the TRANSFER is the only output" + ); + } + + /// Witnesses are read from both of hsd's shapes; one that is not an + /// array of hex items is not hsd's answer. + #[test] + fn spend_view_reads_input_witnesses_from_both_shapes() { + let want = Some(vec![format!("{}84", "aa".repeat(64)), "76".repeat(40)]); + let r = spend_view_from_rest(&rest(120, 9, &p2wpkh(6))).unwrap(); + assert_eq!(r.witnesses[0], want); + let b = spend_view_from_block(&block(120, &p2wpkh(6)), BUY) + .unwrap() + .unwrap(); + assert_eq!(b.witnesses[0], want); + for bad in [ + serde_json::json!("aa"), + serde_json::json!([1]), + serde_json::json!(["zz"]), + ] { + let mut v = rest(120, 9, &p2wpkh(6)); + v["inputs"][0]["witness"] = bad.clone(); + assert!(spend_view_from_rest(&v).is_err(), "{bad}"); + } + } + + #[test] + fn purchase_in_finds_the_transfer_out_of_our_lock_paying_us() { + let (pay, la, n) = (p2wpkh(5), lock_addr(), nh()); + let own: HashSet = [pay.clone()].into(); + let tx = spend_view_from_rest(&rest(120, 9, &pay)).unwrap(); + let at = lock_of(&la, &n); + let p = purchase_of(&own, Some((LOCK, 0)), &pay, &at); + assert_eq!(purchase_in(&tx, &p).unwrap(), Some((LOCK.to_string(), 0))); + // A listing that lost its outpoint (a dead FINALIZE mined after all) + // learns it from the purchase. + let at = lock_of(&la, &n); + let p = purchase_of(&own, None, &pay, &at); + assert_eq!(purchase_in(&tx, &p).unwrap(), Some((LOCK.to_string(), 0))); + } + + #[test] + fn purchase_in_finds_the_lock_coin_at_a_later_input() { + let (pay, la, n) = (p2wpkh(5), lock_addr(), nh()); + let own: HashSet = [pay.clone()].into(); + let mut v = rest(120, 9, &pay); + // Input 1 is the lock coin, so the TRANSFER is output 1; output 0 is + // another output (here a change output). + let (inputs, outputs) = (v["inputs"].clone(), v["outputs"].clone()); + v["inputs"] = serde_json::json!([inputs[1], inputs[0]]); + v["outputs"] = serde_json::json!([outputs[1], outputs[0], outputs[2]]); + let tx = spend_view_from_rest(&v).unwrap(); + let at = lock_of(&la, &n); + let p = purchase_of(&own, Some((LOCK, 0)), &pay, &at); + assert_eq!(purchase_in(&tx, &p).unwrap(), Some((LOCK.to_string(), 0))); + } + + #[test] + fn purchase_in_needs_the_payment_and_a_commitment_not_ours() { + let (pay, la, n) = (p2wpkh(5), lock_addr(), nh()); + let ours = p2wpkh(9); + let own: HashSet = [pay.clone(), ours].into(); + // Commits to an address of ours: a cancel, not a purchase. + let tx = spend_view_from_rest(&rest(120, 9, &pay)).unwrap(); + assert_eq!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&la, &n)) + ) + .unwrap(), + None + ); + let own: HashSet = [pay.clone()].into(); + // Pays another address. + let other_pay = p2wpkh(6); + let tx = spend_view_from_rest(&rest(120, 9, &other_pay)).unwrap(); + assert_eq!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&la, &n)) + ) + .unwrap(), + None + ); + // Spends another lock coin. + let tx = spend_view_from_rest(&rest(120, 9, &pay)).unwrap(); + assert_eq!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 1)), &pay, &lock_of(&la, &n)) + ) + .unwrap(), + None + ); + // A TRANSFER of another name, or at another lock. + let other_name = hex::encode(names::hash_name("other").unwrap()); + assert_eq!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&la, &other_name)) + ) + .unwrap(), + None + ); + let other_lock = p2wpkh(1); + assert_eq!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&other_lock, &n)) + ) + .unwrap(), + None + ); + // The output paying us carries a covenant: not a plain payment. + let mut v = rest(120, 9, &pay); + v["outputs"][2]["covenant"]["type"] = serde_json::json!(2); + let tx = spend_view_from_rest(&v).unwrap(); + assert_eq!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&la, &n)) + ) + .unwrap(), + None + ); + // Output 0 is not a TRANSFER (a FINALIZE of the same name at the lock). + let mut v = rest(120, 9, &pay); + v["outputs"][0]["covenant"]["type"] = serde_json::json!(10); + let tx = spend_view_from_rest(&v).unwrap(); + assert_eq!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&la, &n)) + ) + .unwrap(), + None + ); + // A commitment hsd sent unreadable (not hex, or a version of two bytes). + for (version, hash) in [ + ("zz", "00".repeat(20)), + ("0000", "00".repeat(20)), + ("00", "xy".repeat(20)), + ] { + let mut v = rest(120, 9, &pay); + v["outputs"][0]["covenant"]["items"] = + serde_json::json!([nh(), "32000000", version, hash]); + let tx = spend_view_from_rest(&v).unwrap(); + assert!( + purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&la, &n)) + ) + .is_err(), + "{version} {hash}" + ); + } + // A TRANSFER whose commitment hsd did not send whole: not hsd's answer. + let mut v = rest(120, 9, &pay); + v["outputs"][0]["covenant"]["items"] = serde_json::json!([nh(), "32000000"]); + let tx = spend_view_from_rest(&v).unwrap(); + assert!(purchase_in( + &tx, + &purchase_of(&own, Some((LOCK, 0)), &pay, &lock_of(&la, &n)) + ) + .is_err()); + } } diff --git a/src-tauri/src/shakedex_jobs.rs b/src-tauri/src/shakedex_jobs.rs index 75dc769..893664e 100644 --- a/src-tauri/src/shakedex_jobs.rs +++ b/src-tauri/src/shakedex_jobs.rs @@ -33,20 +33,26 @@ //! profile's node is authoritative, so both the app and the background daemon //! execute it. //! -//! A second step resolves each listing before the FINALIZE into the lock -//! from chain facts (R19): Aborted once the name has left the lock TRANSFER -//! (its Cancel transfer mined, a REVOKE, a lock TRANSFER that never landed), -//! Expired when hsd reports no live name, Locking again if a reorg undoes the -//! abort, ReadyToFinalize once the transfer lockup is over (and Locking again -//! if a reorg moves the lock TRANSFER back), and a Restored lock when the -//! name's owner coin is a FINALIZE at the listing's own lock address, sent -//! from elsewhere — never Aborted -//! ([`refresh_listings_before_lock_with_client`]). A third step follows each -//! listing whose FINALIZE into the lock is built: Listed once it is mined, -//! Finalizing again on a reorg, ReadyToFinalize again if it never landed, -//! and settled as before the lock if it never landed and something else -//! spent the lock TRANSFER ([`refresh_lock_finalize_with_client`]). Both -//! only read the node, and never take the same listing. +//! A second step, [`refresh_listings_step`], runs two jobs on listing sets +//! both read before either job runs. The before-lock job resolves each +//! listing before the FINALIZE into the lock from chain facts (R19): Aborted +//! once the name has left the lock TRANSFER (its Cancel transfer mined, a +//! REVOKE, a lock TRANSFER that never landed), Expired when hsd reports no +//! live name, Locking again if a reorg undoes the abort, ReadyToFinalize +//! once the transfer lockup is over (and Locking again if a reorg moves the +//! lock TRANSFER back), and a Restored lock when the name's owner coin is a +//! FINALIZE at the listing's own lock address, sent from elsewhere — never +//! Aborted ([`refresh_before_lock`]). The after-lock job +//! follows each listing whose FINALIZE into the lock is built: Listed once it +//! is mined, Finalizing again on a reorg, ReadyToFinalize again if it never +//! landed, settled as before the lock if it never landed and something else +//! spent the lock TRANSFER, and, once the lock coin is spent, SalePending or +//! Sold when a purchase of it is found on chain (R22: a TRANSFER out of our +//! lock committing to an address not ours, in a transaction that pays the +//! listing's payment address) ([`refresh_after_lock`]). +//! Both only read the node, and never take the same listing. + +use std::collections::HashSet; use crate::db::queries::{self, PurchaseProgress, PurchaseState, ShakedexPurchase, TxDraftRow}; use crate::error::AppError; @@ -58,7 +64,7 @@ use crate::noncustodial::shakedex::purchase::{self, transfer_commits_to}; use crate::noncustodial::shakedex::script; use crate::noncustodial::shakedex::sell; use crate::noncustodial::shakedex::verify; -use crate::noncustodial::sync::COV_FINALIZE; +use crate::noncustodial::sync::{COV_FINALIZE, COV_TRANSFER}; use crate::noncustodial::tx_evidence; /// Blocks a sent purchase may be absent from the node's mempool and chain @@ -86,6 +92,10 @@ pub(crate) const SEND_GRACE_SECS: i64 = 600; /// reorg. pub const REVIVE_WINDOW_DAYS: u32 = 7; +/// How long a Sold listing is looked at again for a reorg that takes its +/// purchase out of the chain, the same window as a lost purchase's revival. +pub const SOLD_RECHECK_DAYS: u32 = REVIVE_WINDOW_DAYS; + /// hsd's own JSON-RPC error to the broadcast. hsd 8.0.0 answers /// `sendrawtransaction` with the txid whatever its mempool does (it relays /// without awaiting the result), and errors only for input it cannot parse; @@ -847,7 +857,10 @@ async fn cancel_on_chain( #[derive(Debug, PartialEq, Eq)] enum LockOnChain { /// hsd reports no live state for the name (`info: null`): it never - /// existed or has expired. + /// existed or has expired; or the lock TRANSFER belongs to a + /// registration that is gone: mined but not the owner, its covenant's + /// name height is not hsd's `info.height` (the name expired and was + /// opened again, `ns.reset`). NoName, /// The lock TRANSFER owns a name that is not revoked /// ([`sell::lock_transfer_owns_name`]). `transfer` is @@ -860,8 +873,15 @@ enum LockOnChain { /// Neither: `owner` is another outpoint, or the name is revoked /// (a REVOKE leaves `owner` at the coin it spent and sets `revoked`, /// hsd `chain.js`), and `GET /coin` is hsd's empty 404 (spent in a block - /// or in the mempool, or never mined). - Gone { owner: (String, u32), revoked: bool }, + /// or in the mempool, or never mined). `renewal` is hsd's + /// `info.renewal`, the block of the name's last FINALIZE or renewal (a + /// TRANSFER leaves it, `chain.js`): only a hint where to read a + /// FINALIZE without the transaction index ([`spend_view`]). + Gone { + owner: (String, u32), + revoked: bool, + renewal: Option, + }, } /// Read [`LockOnChain`]. Any read error, and a reply without `info`, the @@ -894,28 +914,46 @@ async fn lock_on_chain( }) } else if let Some(coin) = client.get_coin(lock_transfer_txid, 0).await? { // Mined in a block, it would be the owner (or revoked): a node that - // says otherwise is not consistent, so no verdict. + // says otherwise is not consistent, so no verdict — unless the + // registration it belongs to is gone: the name expired and was + // opened again, so hsd's name height is not the one the TRANSFER + // commits to. match coin.mined_height()? { None => Ok(LockOnChain::Pending), - Some(_) => Err(AppError::Rpc(format!( - "node reports lock TRANSFER {lock_transfer_txid}:0 mined but not the name's owner" - ))), + Some(_) => { + let ours = coin + .covenant + .as_ref() + .and_then(purchase::covenant_name_height); + let theirs = info.get("height").and_then(|h| h.as_u64()); + match (ours, theirs) { + (Some(o), Some(t)) if u64::from(o) != t => Ok(LockOnChain::NoName), + _ => Err(AppError::Rpc(format!( + "node reports lock TRANSFER {lock_transfer_txid}:0 mined but not the \ + name's owner" + ))), + } + } } } else { Ok(LockOnChain::Gone { owner: (hash.to_string(), index), revoked: revoked != 0, + renewal: info.get("renewal").and_then(|r| r.as_i64()), }) } } /// Where a name's owner coin sits, from hsd's `GET /coin`. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InOurLock { - /// A FINALIZE at the listing's lock address: the name is in our lock. + /// A FINALIZE of the name at the listing's lock address: the name is in + /// our lock. Finalize, - /// At the listing's lock address, but another covenant (a purchase's - /// TRANSFER or a cancel already): the name went through our lock. + /// At the listing's lock address, but not a FINALIZE of the name (a + /// purchase's TRANSFER, a cancel already, or a covenant of another + /// name, which hsd should never show as this name's owner): never + /// adopted. Other, /// Elsewhere. No, @@ -927,10 +965,9 @@ enum InOurLock { } /// Where the name's owner coin `owner` sits: at this listing's lock address -/// (as a FINALIZE, or another covenant) or elsewhere; hsd's 404 is -/// [`InOurLock::SpentInMempool`]. Only the coin's `address` and -/// `covenant.type` are read; a coin without address or covenant is not -/// hsd's whole answer: an error. +/// (as a FINALIZE of the name, [`sell::ListingLock::holds`], or anything +/// else) or elsewhere; hsd's 404 is [`InOurLock::SpentInMempool`]. A coin +/// without address or covenant is not hsd's whole answer: an error. async fn owner_in_our_lock( client: &dyn NodeRpc, network: Network, @@ -940,56 +977,37 @@ async fn owner_in_our_lock( let Some(coin) = client.get_coin(&owner.0, owner.1).await? else { return Ok(InOurLock::SpentInMempool); }; - let (Some(address), Some(covenant)) = (coin.address.as_deref(), coin.covenant.as_ref()) else { + let Some(at) = sell::CoinAt::of_coin(&coin) else { return Err(AppError::Rpc(format!( "node did not report the address or covenant of coin {}:{}", owner.0, owner.1 ))); }; - if address != listing_lock_address(network, l)? { - Ok(InOurLock::No) - } else if covenant.kind == COV_FINALIZE { + let lock = listing_lock(network, l)?; + if lock.holds(at, COV_FINALIZE, None) { Ok(InOurLock::Finalize) - } else { + } else if lock.is_at(at) { Ok(InOurLock::Other) + } else { + Ok(InOurLock::No) } } -/// The lock address of listing `l`, from its stored lock public key. -fn listing_lock_address( +/// Listing `l`'s lock: the lock address of its stored lock public key, and +/// its name. +fn listing_lock( network: Network, l: &queries::ShakedexListing, -) -> Result { +) -> Result { let pubkey: [u8; 33] = hex::decode(&l.lock_pubkey_hex) .ok() .and_then(|b| b.try_into().ok()) .ok_or_else(|| AppError::Other(format!("corrupted listing {}: bad lock key", l.id)))?; - script::lock_address(network, &pubkey) + sell::ListingLock::new(script::lock_address(network, &pubkey)?, &l.name) } -/// Best-effort sync step: resolve each listing before the FINALIZE into the -/// lock from what the chain shows (see -/// [`refresh_listings_before_lock_with_client`]). Like the other sync steps -/// it returns silently when the database or the profile's node client cannot -/// be opened; a failed refresh is logged. It only reads the node, so it runs -/// in the daemon as it does in the app; the caller runs it only when the node -/// is authoritative, since a node that is behind would show a mined cancel's -/// lock TRANSFER as unspent. -pub async fn refresh_listings_before_lock_step(db_path: &str, profile_id: &str) { - let conn = match crate::db::connection::open_migrated(db_path) { - Ok(c) => c, - Err(_) => return, - }; - let client = match NodeRpcClient::for_profile(&conn, profile_id) { - Ok(c) => c, - Err(_) => return, - }; - if let Err(e) = refresh_listings_before_lock_with_client(&conn, &client, profile_id).await { - eprintln!("shakedex listings: lock refresh failed for {profile_id}: {e}"); - } -} - -/// R19, for each listing still Locking or ReadyToFinalize, or Aborted within +/// The before-lock job of [`refresh_listings_with_client`] (R19), for one +/// listing still Locking or ReadyToFinalize, or Aborted within /// [`ABORT_RECHECK_DAYS`]. Every verdict rests on a found fact, never on the /// broadcast (hsd answers `sendrawtransaction` with the txid even when it /// refuses): @@ -1005,38 +1023,23 @@ pub async fn refresh_listings_before_lock_step(db_path: &str, profile_id: &str) /// again → Locking; /// - but the owner coin a FINALIZE at this listing's lock address (a /// FINALIZE into our lock this device did not build) → Restored with that -/// outpoint, never Aborted; another covenant there → unchanged (T4's); -/// the owner coin hsd's 404 (spent in the mempool) → unchanged until that +/// outpoint, never Aborted; another covenant there → unchanged; the +/// owner coin hsd's 404 (spent in the mempool) → unchanged until that /// spend is mined; +/// - and before either of those last two, a mined purchase of a FINALIZE of +/// this listing into the lock paying its payment address → Sold (R22, +/// [`sale_of_left_lock`]); /// - the lock TRANSFER the owner and `blocks_until_finalize` of hsd's /// `info.transfer` 0 at the tip → ReadyToFinalize, not 0 → Locking; the /// lock TRANSFER a coin in the mempool (`height: -1`), not the owner → /// Locking; a lock TRANSFER mined in a block but not the owner is not a -/// consistent answer → unchanged. +/// consistent answer → unchanged, unless its covenant's name height is not +/// hsd's (the name expired and was opened again) → Expired. /// /// Locking again is refused while another listing of the name is open /// ([`queries::unabort_shakedex_listing`]). Anything the node does not -/// answer leaves the listing as it is. Sends nothing. A failure on one -/// listing is logged and leaves it for the next sync. -pub async fn refresh_listings_before_lock_with_client( - conn: &rusqlite::Connection, - client: &dyn NodeRpc, - profile_id: &str, -) -> Result<(), AppError> { - let listings = - queries::list_shakedex_listings_before_lock(conn, profile_id, ABORT_RECHECK_DAYS)?; - if listings.is_empty() { - return Ok(()); - } - let network = queries::profile_network(conn, profile_id)?; - for l in listings { - if let Err(e) = refresh_before_lock(conn, client, network, &l).await { - eprintln!("shakedex listings: {} ({}): {e}", l.id, l.name); - } - } - Ok(()) -} - +/// answer leaves the listing as it is. Sends nothing. An error leaves the +/// listing for the next sync (the caller logs it). async fn refresh_before_lock( conn: &rusqlite::Connection, client: &dyn NodeRpc, @@ -1100,14 +1103,19 @@ async fn refresh_before_lock( /// for a Finalizing one whose FINALIZE is dead (the SQL writes accept no /// other source, [`queries::abort_shakedex_listing`]): /// -/// - hsd reports no live name → Expired; +/// - hsd reports no live name, or the lock TRANSFER belongs to a +/// registration that is gone → Expired; /// - the name revoked, or its owner coin readable elsewhere while the lock /// draft can no longer land → Aborted; /// - the owner coin a FINALIZE at this listing's lock address → Restored /// with that outpoint, never Aborted (coordinator (b): only that positive /// evidence says the name is in our lock, whoever sent the FINALIZE); -/// - the owner coin at our lock under another covenant (T4 decides), or -/// hsd's 404 for it (spent in the mempool) → unchanged. +/// - but a mined purchase of a FINALIZE of this listing into the lock +/// paying its payment address (R22, [`sale_of_left_lock`]) → Sold, never +/// Aborted; a purchase found whose lock coin hsd does not show to be this +/// listing's → unchanged; +/// - the owner coin at our lock under another covenant, or hsd's 404 for +/// it (spent in the mempool) → unchanged. async fn settle_left_lock( conn: &rusqlite::Connection, client: &dyn NodeRpc, @@ -1115,22 +1123,55 @@ async fn settle_left_lock( l: &queries::ShakedexListing, lock: LockOnChain, ) -> Result<(), AppError> { - let (owner, revoked) = match lock { + let (owner, revoked, renewal) = match lock { LockOnChain::NoName => { queries::expire_shakedex_listing(conn, &l.id)?; return Ok(()); } - LockOnChain::Gone { owner, revoked } => (owner, revoked), + LockOnChain::Gone { + owner, + revoked, + renewal, + } => (owner, revoked, renewal), LockOnChain::Owner { .. } | LockOnChain::Pending => return Ok(()), }; if !revoked { - match owner_in_our_lock(client, network, l, &owner).await? { + let where_ = owner_in_our_lock(client, network, l, &owner).await?; + match where_ { InOurLock::Finalize => { queries::adopt_lock_finalized_elsewhere(conn, &l.id, &owner.0, owner.1)?; return Ok(()); } - InOurLock::Other | InOurLock::SpentInMempool => return Ok(()), - InOurLock::No => {} + InOurLock::SpentInMempool => return Ok(()), + InOurLock::Other | InOurLock::No => { + // R22: the name may have left through our lock — a FINALIZE + // of ours mined after all, its lock coin bought. + match sale_of_left_lock(conn, client, network, l, &owner, renewal).await? { + LeftSale::Sold { txid, lock, proven } => { + let lock = (lock.0.as_str(), lock.1); + let n = if proven { + queries::sell_listing_through_proven_lock(conn, &l.id, &txid, lock)? + } else { + queries::sell_shakedex_listing(conn, &l.id, &txid, lock)? + }; + if n == 0 { + eprintln!( + "shakedex listings: {} ({}): purchase {txid} of lock coin {}:{} \ + found, but the listing was not moved to sold from {}", + l.id, + l.name, + lock.0, + lock.1, + l.state.as_str() + ); + } + return Ok(()); + } + LeftSale::Unproven => return Ok(()), + LeftSale::None if where_ == InOurLock::Other => return Ok(()), + LeftSale::None => {} + } + } } } let status = queries::lock_draft_status(conn, l)?; @@ -1140,6 +1181,131 @@ async fn settle_left_lock( Ok(()) } +/// What [`sale_of_left_lock`] found. +#[derive(Debug, PartialEq, Eq)] +enum LeftSale { + /// A mined purchase of this listing's lock coin `lock`; `proven` when + /// the listing had no lock outpoint and `lock` was tied to it by + /// [`finalize_into_lock`] (written by + /// [`queries::sell_listing_through_proven_lock`]). + Sold { + txid: String, + lock: (String, u32), + proven: bool, + }, + /// A mined purchase paying the payment address out of our lock address, + /// but hsd does not show the coin it spends as this listing's lock coin + /// (not readable, or not a FINALIZE spending its lock TRANSFER): no + /// verdict either way. + Unproven, + /// No mined purchase. + None, +} + +/// R22 for a listing whose name left the lock TRANSFER +/// ([`settle_left_lock`]), the name's owner being `owner`. A listing with a +/// stored lock outpoint is judged by [`find_sale`]. One without (a dead +/// FINALIZE's listing back at ReadyToFinalize, or still Locking) has no +/// outpoint for [`find_sale`] to tie a purchase to: a purchase found out of +/// our lock address with any input ([`find_purchases`], every mined one) is +/// only a lead to the coin it spends, tried in turn: it is the lock only +/// when hsd shows it as a +/// FINALIZE at this listing's lock address spending this listing's lock +/// TRANSFER ([`finalize_into_lock`]); [`find_sale`] then judges the +/// listing with that outpoint. +async fn sale_of_left_lock( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + owner: &(String, u32), + renewal: Option, +) -> Result { + if l.lock_txid.is_some() && l.lock_vout.is_some() { + return Ok(match find_sale(conn, client, network, l, owner).await? { + Sale::Mined { txid, lock } => LeftSale::Sold { + txid, + lock, + proven: false, + }, + _ => LeftSale::None, + }); + } + let leads = find_purchases(conn, client, network, l, owner, None).await?; + if leads.is_empty() { + return Ok(LeftSale::None); + } + for lead in leads { + let Sale::Mined { lock, .. } = lead else { + continue; + }; + let proven = match finalize_into_lock(client, network, l, &lock, renewal).await { + Ok(p) => p, + // Skipping withholds a verdict, never makes one. + Err(e) => { + eprintln!( + "shakedex listings: {} ({}): transaction {} could not be read, skipped: {e}", + l.id, l.name, lock.0 + ); + continue; + } + }; + if !proven { + eprintln!( + "shakedex listings: {} ({}): a purchase out of our lock spends {}:{}, which the \ + node does not show as this listing's FINALIZE: not this listing's sale", + l.id, l.name, lock.0, lock.1 + ); + continue; + } + let tied = queries::ShakedexListing { + lock_txid: Some(lock.0), + lock_vout: Some(i64::from(lock.1)), + ..l.clone() + }; + if let Sale::Mined { txid, lock } = find_sale(conn, client, network, &tied, owner).await? { + return Ok(LeftSale::Sold { + txid, + lock, + proven: true, + }); + } + } + Ok(LeftSale::Unproven) +} + +/// Whether hsd shows `lock` as listing `l`'s lock coin: its transaction, +/// read with [`spend_view`] (`renewal` the block to read it at without the +/// index), is in a block, output `lock.1` is a FINALIZE of the name at the +/// listing's lock address, and input `lock.1` (hsd links a FINALIZE to the +/// input at its index) spends the listing's lock TRANSFER. Not found, or +/// anything else: `false`. +async fn finalize_into_lock( + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + lock: &(String, u32), + renewal: Option, +) -> Result { + let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() else { + return Ok(false); + }; + let Some(tx) = spend_view(client, &lock.0, renewal).await? else { + return Ok(false); + }; + let k = lock.1 as usize; + let at = listing_lock(network, l)?; + let spends_our_transfer = tx + .inputs + .get(k) + .is_some_and(|(t, v)| t == lock_transfer_txid && *v == 0); + let is_our_lock = tx + .outputs + .get(k) + .is_some_and(|o| at.holds(sell::CoinAt::of_output(o), COV_FINALIZE, None)); + Ok(tx.height.is_some() && spends_our_transfer && is_our_lock) +} + fn relock(conn: &rusqlite::Connection, l: &queries::ShakedexListing) -> Result<(), AppError> { if queries::unabort_shakedex_listing(conn, &l.id)? == 0 { eprintln!( @@ -1152,17 +1318,18 @@ fn relock(conn: &rusqlite::Connection, l: &queries::ShakedexListing) -> Result<( } // --------------------------------------------------------------------------- -// Listings after the FINALIZE into the lock is built (R19) +// Listings after the FINALIZE into the lock is built (R19, R22) // --------------------------------------------------------------------------- -/// Best-effort sync step: follow each listing whose FINALIZE into the lock -/// is built (Finalizing) or mined (Listed) (see -/// [`refresh_lock_finalize_with_client`]). Like the other sync steps it -/// returns silently when the database or the profile's node client cannot -/// be opened; a failed refresh is logged. It only reads the node, so it runs -/// in the daemon as it does in the app; the caller runs it only when the node -/// is authoritative. -pub async fn refresh_lock_finalize_step(db_path: &str, profile_id: &str) { +/// Best-effort sync step for every listing (R19, R22): the before-lock job, +/// then the after-lock job, on listing sets both read before either runs +/// ([`refresh_listings_with_client`]). Like the other sync steps it returns +/// silently when the database or the profile's node client cannot be +/// opened; a failed refresh is logged. It only reads the node and the +/// database, so the daemon runs it as the app does; the caller runs it only +/// when the node is authoritative, since a node that is behind would show a +/// mined cancel's lock TRANSFER as unspent and a mined purchase as missing. +pub async fn refresh_listings_step(db_path: &str, profile_id: &str) { let conn = match crate::db::connection::open_migrated(db_path) { Ok(c) => c, Err(_) => return, @@ -1171,96 +1338,580 @@ pub async fn refresh_lock_finalize_step(db_path: &str, profile_id: &str) { Ok(c) => c, Err(_) => return, }; - if let Err(e) = refresh_lock_finalize_with_client(&conn, &client, profile_id).await { - eprintln!("shakedex listings: finalize refresh failed for {profile_id}: {e}"); + if let Err(e) = refresh_listings_with_client(&conn, &client, profile_id).await { + eprintln!("shakedex listings: refresh failed for {profile_id}: {e}"); } } -/// R19, for every Finalizing or Listed listing, from hsd's `GET /coin` of -/// its lock outpoint `(lock_txid, lock_vout)`: -/// -/// - a FINALIZE at the listing's lock address mined in a block → Listed; in -/// the mempool (`height: -1`) → Finalizing (a reorg took it back); -/// - hsd's 404 for a Finalizing listing whose FINALIZE draft is `failed`, -/// `dropped` or gone ([`finalize_dead`]): the lock TRANSFER -/// `(lock_transfer_txid, 0)` a coin again → ReadyToFinalize, its lock -/// outpoint, steps and file dropped (they were signed over a coin that -/// does not exist); the lock TRANSFER hsd's 404 too, so something else -/// spent it → settled from the name as before the lock -/// ([`settle_left_lock`]: Expired, Aborted, Restored, or unchanged); -/// - anything else (a Listed lock coin spent: sold or cancelled, T4's), a -/// coin at another address or of another covenant, a reply missing the -/// coin's address, covenant or height, or a read error → unchanged. -/// -/// Sends nothing. A failure on one listing is logged and leaves it for the -/// next sync. -pub async fn refresh_lock_finalize_with_client( +/// Both listing jobs, each on the set it takes +/// ([`queries::ListingState::BEFORE_LOCK_JOB`] for [`refresh_before_lock`]'s +/// rules, [`queries::ListingState::AFTER_LOCK_JOB`] for +/// [`refresh_after_lock`]'s), both sets read first: a +/// listing one job moves into the other's set is judged once per sync. Sends +/// nothing. A failure on one listing is logged and leaves it for the next +/// sync. +pub async fn refresh_listings_with_client( conn: &rusqlite::Connection, client: &dyn NodeRpc, profile_id: &str, ) -> Result<(), AppError> { - let listings = queries::list_shakedex_listings_finalizing(conn, profile_id)?; - if listings.is_empty() { + let before = queries::list_shakedex_listings_before_lock(conn, profile_id, ABORT_RECHECK_DAYS)?; + let after = queries::list_shakedex_listings_after_lock(conn, profile_id, SOLD_RECHECK_DAYS)?; + if before.is_empty() && after.is_empty() { return Ok(()); } let network = queries::profile_network(conn, profile_id)?; - for l in listings { - if let Err(e) = refresh_lock_finalize(conn, client, network, &l).await { + for l in &before { + if let Err(e) = refresh_before_lock(conn, client, network, l).await { + eprintln!("shakedex listings: {} ({}): {e}", l.id, l.name); + } + } + for l in &after { + if let Err(e) = refresh_after_lock(conn, client, network, l).await { eprintln!("shakedex listings: {} ({}): {e}", l.id, l.name); } } Ok(()) } -async fn refresh_lock_finalize( +/// The after-lock job of [`refresh_listings_with_client`] (R19, R22), for +/// one Finalizing, Listed, SalePending or Restored listing, or a Sold one +/// within [`SOLD_RECHECK_DAYS`], from hsd's `GET /coin` of its lock outpoint +/// `(lock_txid, lock_vout)`: +/// +/// - a FINALIZE of the name at the listing's lock address mined in a block → a +/// Finalizing listing Listed; in the mempool (`height: -1`) → a Listed one +/// Finalizing (a reorg took it back); a coin at all → a SalePending or +/// Sold listing Listed (Finalizing while that coin is in the mempool and +/// our FINALIZE draft exists; Restored without a listing file), its +/// purchase forgotten, unless another listing of the name is open by then; +/// mined, +/// with the name's live state gone or its height not the lock coin's → a +/// Listed or Restored listing Expired ([`registration_ended`]); +/// - hsd's 404 for the lock coin while the lock TRANSFER +/// `(lock_transfer_txid, 0)` is a coin again (the FINALIZE into the lock +/// in no block and no mempool): a Finalizing listing whose FINALIZE draft +/// is `failed`, `dropped` or gone ([`finalize_dead`]) → ReadyToFinalize, +/// its lock outpoint, steps and file dropped (they were signed over a +/// coin that does not exist); a Listed one, and a SalePending or Sold one +/// with our FINALIZE draft → Finalizing; a SalePending or Sold one without +/// it (adopted from another device's FINALIZE) → Restored; a Restored one +/// → Locking without its outpoint ([`queries::unadopt_restored_lock`]); +/// - hsd's 404 for both, for a Finalizing listing whose FINALIZE is dead: +/// something else spent the lock TRANSFER → settled from the name as +/// before the lock ([`settle_left_lock`]: Expired, Aborted, Restored, or +/// unchanged); +/// - any other 404 (the lock coin spent in a block or in the mempool) is no +/// verdict alone: the name's owner is read; no live name → a Listed, +/// SalePending or Restored listing Expired; otherwise a purchase is looked +/// for ([`find_sale`]); one in the mempool while the owner is still the +/// lock coin → SalePending, one mined while the owner has moved → Sold; a +/// lock restored by name (no payment address) → Sold only by a mined price +/// step (sighash `0x84`) out of its lock coin ([`sale_of_restored_lock`]); +/// a Sold listing moves only by [`queries::resell_sold_listing`]: back to +/// SalePending when its purchase is in the mempool again, or to the txid +/// of another purchase of its lock coin mined instead; +/// - a coin at another address or of another covenant, a reply missing the +/// coin's address, covenant or height, a name reply missing `info` or the +/// owner, or a read error → unchanged. +/// +/// Sends nothing. An error leaves the listing for the next sync (the caller +/// logs it). +async fn refresh_after_lock( conn: &rusqlite::Connection, client: &dyn NodeRpc, network: Network, l: &queries::ShakedexListing, ) -> Result<(), AppError> { - let (Some(lock_txid), Some(lock_vout)) = (l.lock_txid.as_deref(), l.lock_vout) else { + let Some((lock_txid, lock_vout)) = stored_lock(l)? else { return Ok(()); }; - let lock_vout = u32::try_from(lock_vout) - .map_err(|_| AppError::Other(format!("corrupted listing {}: bad lock output", l.id)))?; - let Some(coin) = client.get_coin(lock_txid, lock_vout).await? else { - if l.state == queries::ListingState::Finalizing && finalize_dead(conn, l)? { - let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() else { - return Ok(()); - }; - if client.get_coin(lock_transfer_txid, 0).await?.is_some() { - // Our FINALIZE never landed and the lock TRANSFER is a coin - // again: Finalize & sign may run again. - queries::revert_listing_to_ready(conn, &l.id)?; - } else { - // Something else spent the lock TRANSFER (an older cancel - // mined later, another device's FINALIZE, a REVOKE): settle - // it as before the lock. A 404 that only means "spent in - // the mempool" leaves it, as there. - let lock = lock_on_chain(client, &l.name, lock_transfer_txid).await?; - settle_left_lock(conn, client, network, l, lock).await?; - } - } - return Ok(()); + match client.get_coin(lock_txid, lock_vout).await? { + Some(coin) => lock_coin_held(conn, client, network, l, &coin, (lock_txid, lock_vout)).await, + None => lock_coin_spent(conn, client, network, l, (lock_txid, lock_vout)).await, + } +} + +/// Whether the registration a locked listing's lock coin belongs to is gone: +/// hsd reports no live state for the name (`info: null`), or the name's +/// height is not the one the lock coin's covenant commits to (it expired and +/// was opened again, `ns.reset`). `lock_height` `None` (a covenant without a +/// readable height item) decides only the first. A live name without its +/// height is not hsd's whole answer: an error. +fn registration_ended( + reply: &serde_json::Value, + lock_height: Option, +) -> Result { + let Some(info) = name_info(reply)? else { + return Ok(true); }; - let (Some(address), Some(covenant)) = (coin.address.as_deref(), coin.covenant.as_ref()) else { + let height = info + .get("height") + .and_then(|h| h.as_u64()) + .ok_or_else(|| AppError::Rpc("node did not report the name's height".into()))?; + Ok(lock_height.is_some_and(|h| u64::from(h) != height)) +} + +/// The lock coin is a coin: a FINALIZE at the listing's lock address. Mined, +/// a Finalizing listing is Listed; in the mempool (`height: -1`), a Listed +/// one is Finalizing (a reorg took it back). A SalePending or Sold listing +/// goes back to Listed (Restored without a listing file): hsd answers 404 for +/// a coin any mempool transaction spends, so its purchase is in no block and +/// no mempool of this node. A Listed or Restored listing whose name has no +/// live state, or whose name height is not the lock coin's, is Expired +/// ([`registration_ended`]). Anything else at that outpoint is an error, and +/// the listing stays as it is. +async fn lock_coin_held( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + coin: &crate::noncustodial::rpc::NodeCoin, + lock: (&str, u32), +) -> Result<(), AppError> { + let (Some(at), Some(covenant)) = (sell::CoinAt::of_coin(coin), coin.covenant.as_ref()) else { return Err(AppError::Rpc(format!( - "node did not report the address or covenant of lock coin {lock_txid}:{lock_vout}" + "node did not report the address or covenant of lock coin {}:{}", + lock.0, lock.1 ))); }; - if address != listing_lock_address(network, l)? || covenant.kind != COV_FINALIZE { + if !listing_lock(network, l)?.holds(at, COV_FINALIZE, None) { return Err(AppError::Other(format!( - "lock coin {lock_txid}:{lock_vout} is not a FINALIZE at the listing's lock address" + "lock coin {}:{} is not a FINALIZE of the name at the listing's lock address", + lock.0, lock.1 ))); } - if coin.mined_height()?.is_some() { - queries::mark_listing_listed(conn, &l.id)?; - } else { - queries::mark_listing_finalizing_again(conn, &l.id)?; + // Each write moves only the state it names; any other state is left as + // it is. + match (l.state, coin.mined_height()?) { + (queries::ListingState::Finalizing, Some(_)) => { + queries::mark_listing_listed(conn, &l.id)?; + } + (queries::ListingState::Listed, None) => { + queries::mark_listing_finalizing_again(conn, &l.id)?; + } + (queries::ListingState::SalePending | queries::ListingState::Sold, mined) => { + let seen = match mined { + Some(_) => LockFinalizeSeen::Mined, + None => LockFinalizeSeen::Mempool, + }; + if queries::unsell_shakedex_listing(conn, &l.id, unsell_target(l, seen))? == 0 { + eprintln!( + "shakedex listings: {} ({}): its purchase is no longer on chain, but it \ + stays sold: another listing of the name is open", + l.id, l.name + ); + } + } + (queries::ListingState::Listed | queries::ListingState::Restored, Some(_)) => { + let reply = client.get_name_info(&l.name).await?; + if registration_ended(&reply, purchase::covenant_name_height(covenant))? { + queries::expire_locked_listing(conn, &l.id)?; + } + } + _ => {} + } + Ok(()) +} + +/// Where hsd shows the FINALIZE into the lock of a listing whose purchase +/// is gone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LockFinalizeSeen { + /// The lock coin is a coin mined in a block. + Mined, + /// The lock coin is a coin in the mempool (`height: -1`). + Mempool, + /// In no block and no mempool: the lock TRANSFER is a coin again. + Nowhere, +} + +/// Where a SalePending or Sold listing goes back to when its purchase is +/// gone (R22, [`queries::unsell_shakedex_listing`]): Finalizing while the +/// FINALIZE into the lock is not mined and the listing has our FINALIZE +/// draft (its file is not exported over an unmined FINALIZE); Listed when +/// the lock coin is a coin and the listing has its file; Restored otherwise +/// (no file, or a FINALIZE from another device that is in no block and no +/// mempool: the next sync takes a Restored lock adopted from our lock +/// TRANSFER to Locking). +fn unsell_target(l: &queries::ShakedexListing, seen: LockFinalizeSeen) -> queries::ListingState { + use queries::ListingState as S; + let draft = l.lock_finalize_draft_id.is_some(); + let file = l.listing_file_json.is_some(); + match seen { + LockFinalizeSeen::Mempool | LockFinalizeSeen::Nowhere if draft => S::Finalizing, + LockFinalizeSeen::Mined | LockFinalizeSeen::Mempool if file => S::Listed, + _ => S::Restored, + } +} + +/// hsd's 404 for the lock coin: spent in a block or in the mempool +/// (`fullnode.js` `getCoin`, `mempool.isSpent`). Never a verdict alone. +async fn lock_coin_spent( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + lock: (&str, u32), +) -> Result<(), AppError> { + // The lock TRANSFER a coin again: hsd answers 404 for a coin any mempool + // transaction spends, so the FINALIZE into the lock is in no block and + // no mempool of this node (plan deviation 4). + if let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() { + if client.get_coin(lock_transfer_txid, 0).await?.is_some() { + match l.state { + // Our FINALIZE never landed: Finalize & sign may run again. + queries::ListingState::Finalizing if finalize_dead(conn, l)? => { + queries::revert_listing_to_ready(conn, &l.id)?; + } + queries::ListingState::Listed => { + queries::mark_listing_finalizing_again(conn, &l.id)?; + } + queries::ListingState::SalePending | queries::ListingState::Sold => { + let to = unsell_target(l, LockFinalizeSeen::Nowhere); + if queries::unsell_shakedex_listing(conn, &l.id, to)? == 0 { + eprintln!( + "shakedex listings: {} ({}): its FINALIZE is no longer on chain, \ + but it stays sold: another listing of the name is open", + l.id, l.name + ); + } + } + queries::ListingState::Restored => { + queries::unadopt_restored_lock(conn, &l.id)?; + } + _ => {} + } + return Ok(()); + } + } + if l.state == queries::ListingState::Finalizing && finalize_dead(conn, l)? { + // Something else spent the lock TRANSFER (an older cancel mined + // later, another device's FINALIZE, a REVOKE): settle it as before + // the lock. A 404 that only means "spent in the mempool" leaves it, + // as there. + let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() else { + return Ok(()); + }; + let gone = lock_on_chain(client, &l.name, lock_transfer_txid).await?; + settle_left_lock(conn, client, network, l, gone).await?; + return Ok(()); + } + let reply = client.get_name_info(&l.name).await?; + let Some(info) = name_info(&reply)? else { + // No live name: the listing ended with it (R31's lockup risk, or a + // listing left to expire). Sold stays Sold, and a Finalizing + // listing is not moved ([`queries::expire_locked_listing`]). + queries::expire_locked_listing(conn, &l.id)?; + return Ok(()); + }; + let owner = owner_of(info)?; + let owner_is_lock = owner.0 == lock.0 && owner.1 == lock.1; + if l.payment_address.is_none() { + // A lock restored by name: no payment address to find a purchase + // by, only the owner. While the owner is still the lock coin, hsd's + // `GET /coin` of it is the 404 that brought us here: no verdict. + if let Some(txid) = sale_of_restored_lock(conn, client, network, l, &owner, lock).await? { + queries::sell_shakedex_listing(conn, &l.id, &txid, lock)?; + } + return Ok(()); + } + // hsd moves the owner only when a block is connected: a purchase is + // Pending while the owner is still the lock coin, and Mined only once it + // is not. Two facts that disagree are no verdict. + match ( + find_sale(conn, client, network, l, &owner).await?, + owner_is_lock, + ) { + // A Sold listing whose purchase is back in the mempool (a reorg), + // or whose lock coin another purchase bought instead: only Sold's + // own guarded write moves it. + (Sale::Pending { txid, lock }, true) if l.state == queries::ListingState::Sold => { + let to = queries::ListingState::SalePending; + let lock = (lock.0.as_str(), lock.1); + if queries::resell_sold_listing(conn, &l.id, to, &txid, lock)? == 0 { + eprintln!( + "shakedex listings: {} ({}): its purchase is back in the mempool, but it \ + stays sold: another listing of the name is open", + l.id, l.name + ); + } + } + // The purchase the listing was sold by: nothing to write (a write + // would move `updated_at`, and the re-check window would never + // close). + (Sale::Mined { txid, .. }, false) + if l.state == queries::ListingState::Sold + && l.sold_txid.as_deref().is_some_and(|s| s == txid) => {} + (Sale::Mined { txid, lock }, false) if l.state == queries::ListingState::Sold => { + let to = queries::ListingState::Sold; + queries::resell_sold_listing(conn, &l.id, to, &txid, (lock.0.as_str(), lock.1))?; + } + (Sale::Pending { txid, lock }, true) => { + queries::mark_listing_sale_pending(conn, &l.id, &txid, (lock.0.as_str(), lock.1))?; + } + (Sale::Mined { txid, lock }, false) => { + queries::sell_shakedex_listing(conn, &l.id, &txid, (lock.0.as_str(), lock.1))?; + } + _ => {} } Ok(()) } +/// R22 for a Restored lock without a payment address (restored by name, +/// R32), the name's owner being `owner` and its lock coin `lock` spent: the +/// owner coin's transaction, read with `GET /tx` or, without the index, in +/// the block at the owner coin's height (`GET /coin`), is +/// [`sell::sale_out_of_restored_lock`] at the owner's index: its txid then. +/// Anything else is `None`, no verdict: the owner coin hsd's 404 (spent in +/// the mempool; or the lock coin itself, still the owner while a purchase +/// of it is in the mempool), its transaction not found or not in a block, +/// or not a price step's purchase of this lock coin (our cancel, `0x83`, is +/// T5's). A lock input without its witness is an error. When the buyer's +/// FINALIZE is mined before a sync sees the TRANSFER as the owner, the +/// owner's transaction is that FINALIZE: not followed back, no verdict. Only the write's source states move +/// ([`queries::ListingWrite::Sell`]). +async fn sale_of_restored_lock( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + owner: &(String, u32), + lock: (&str, u32), +) -> Result, AppError> { + let Some(coin) = client.get_coin(&owner.0, owner.1).await? else { + return Ok(None); + }; + let Some(tx) = spend_view(client, &owner.0, coin.mined_height()?).await? else { + return Ok(None); + }; + let own: HashSet = queries::get_profile_addresses(conn, &l.wallet_profile_id)? + .into_iter() + .collect(); + let at = listing_lock(network, l)?; + let sold = sell::sale_out_of_restored_lock(&tx, owner.1, lock, &at, network, &own)?; + Ok(sold.then(|| owner.0.clone())) +} + +/// What the chain shows about a purchase of a listing's lock coin (R22). +/// `lock` is the coin the purchase spends, the input [`sell::purchase_in`] +/// found spending the listing's stored lock outpoint (a FINALIZE at its lock +/// address, checked when the listing got it), which the database write +/// compares again ([`queries::sell_shakedex_listing`]). +#[derive(Debug, PartialEq, Eq)] +enum Sale { + /// Mined in a block. + Mined { txid: String, lock: (String, u32) }, + /// In the node's mempool. + Pending { txid: String, lock: (String, u32) }, + /// No purchase found: no verdict. + None, +} + +/// The name's owner outpoint from `info`; a reply without `owner.hash` or +/// `owner.index` is not hsd's whole answer. +fn owner_of(info: &serde_json::Value) -> Result<(String, u32), AppError> { + let owner = info.get("owner"); + let hash = owner.and_then(|o| o.get("hash")).and_then(|h| h.as_str()); + let index = owner + .and_then(|o| o.get("index")) + .and_then(|i| i.as_u64()) + .and_then(|i| u32::try_from(i).ok()); + match (hash, index) { + (Some(h), Some(i)) => Ok((h.to_string(), i)), + _ => Err(AppError::Rpc("node did not report the name's owner".into())), + } +} + +/// hsd's view of transaction `txid`: `GET /tx` (the mempool always, a block +/// only with `--index-tx`); on hsd's not-found, the block at `seen_at`, the +/// height our coin of it was last seen mined at (`getblockhash` + +/// `getblock`, no index needed). `None`: not found where it was looked for +/// (a coin row of a transaction that left the mempool, or a block that a +/// reorg replaced), so no verdict. +async fn spend_view( + client: &dyn NodeRpc, + txid: &str, + seen_at: Option, +) -> Result, AppError> { + let tx = client.get_tx_by_hash(txid).await?; + if !tx.is_null() { + return sell::spend_view_from_rest(&tx).map(Some); + } + let Some(height) = seen_at.filter(|h| *h >= 0) else { + return Ok(None); + }; + let hash = client.get_block_hash(height).await?; + let block = client.get_block(&hash).await?; + sell::spend_view_from_block(&block, txid) +} + +/// Transaction `txid` found on chain ([`spend_view`], `seen_at` the height +/// to read its block at without the index) and judged by +/// [`sell::purchase_in`]: its block height (`None` in the mempool) and the +/// lock coin it spends, or `None` when it is not found or is not a purchase +/// of `p`'s lock coin. +async fn purchase_found( + client: &dyn NodeRpc, + txid: &str, + seen_at: Option, + p: &sell::PurchaseOf<'_>, +) -> Result, (String, u32))>, AppError> { + let Some(tx) = spend_view(client, txid, seen_at).await? else { + return Ok(None); + }; + Ok(sell::purchase_in(&tx, p)?.map(|spent| (tx.height, spent))) +} + +/// R22: look for a purchase of listing `l`'s lock coin, the name's owner +/// being `owner`. Nothing without a payment address (a lock restored by +/// name, until its file is imported) or without a lock outpoint. Every +/// verdict rests on the purchase transaction found on chain and judged by +/// [`sell::purchase_in`] against the listing's STORED lock outpoint (every +/// lock coin of a name sits at the same lock address, ADR 0004, so the +/// address alone does not say which listing was bought); the lock coin +/// returned is the input `purchase_in` found. A coin of ours in +/// `tracked_utxos` is only a lead to a transaction. Two ways, neither needing +/// hsd's transaction index: +/// +/// - the owner first: hsd moves it to the purchase's TRANSFER (its output 0) +/// when its block is connected. When the wallet has a coin of that +/// transaction at the payment address and hsd shows the owner as a coin +/// that is a TRANSFER of the name at our lock address: committing to an +/// address of ours it is our cancel → no verdict (T5); in the mempool it +/// is not a mined owner → no verdict; mined, its transaction is read +/// (`GET /tx`, or the block at the owner coin's own height) and is Mined +/// when `purchase_in` accepts it and it is in a block; +/// - otherwise (the buyer finalized since, the purchase is in the mempool, +/// or the owner is another lock coin's purchase) each transaction that +/// paid our payment address, read the same way at the height our coin of +/// it was seen mined: accepted in the mempool → Pending, in a block → +/// Mined. +/// +/// A lead (or the owner's transaction) that cannot be read is logged and +/// skipped: skipping can only withhold a verdict, never make one, since any +/// verdict still needs its own transaction found and accepted, so one +/// unreadable transaction does not hold the listing for ever. +async fn find_sale( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + owner: &(String, u32), +) -> Result { + let Some(lock) = stored_lock(l)? else { + return Ok(Sale::None); + }; + let found = find_purchases(conn, client, network, l, owner, Some(lock)).await?; + // `find_purchases` with a lock returns at most the first purchase found; + // none found is no verdict, as `Sale::None` says. + Ok(found.into_iter().next().unwrap_or(Sale::None)) +} + +/// Listing `l`'s stored lock outpoint, `None` while it has none; a stored +/// output index that is not a `u32` is a corrupted row. +fn stored_lock(l: &queries::ShakedexListing) -> Result, AppError> { + let (Some(txid), Some(vout)) = (l.lock_txid.as_deref(), l.lock_vout) else { + return Ok(None); + }; + let vout = u32::try_from(vout) + .map_err(|_| AppError::Other(format!("corrupted listing {}: bad lock output", l.id)))?; + Ok(Some((txid, vout))) +} + +/// [`find_sale`]'s search with the lock coin `lock` the purchase must spend: +/// the first purchase found, mined or in the mempool. `None` accepts a +/// purchase of any coin at the listing's lock address and returns every +/// mined one found (in the mempool they are skipped); the coin each spends +/// is only a lead, never a listing's lock outpoint until +/// [`finalize_into_lock`] ties it to the listing ([`sale_of_left_lock`]). +async fn find_purchases( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + owner: &(String, u32), + lock: Option<(&str, u32)>, +) -> Result, AppError> { + let Some(payment) = l.payment_address.as_deref() else { + return Ok(vec![]); + }; + let first_only = lock.is_some(); + let mut found: Vec = Vec::new(); + let profile = &l.wallet_profile_id; + let at = listing_lock(network, l)?; + let own: HashSet = queries::get_profile_addresses(conn, profile)? + .into_iter() + .collect(); + let p = sell::PurchaseOf { + network, + lock, + at: &at, + payment_address: payment, + own: &own, + }; + let unreadable = |txid: &str, e: AppError| { + eprintln!( + "shakedex listings: {} ({}): transaction {txid} could not be read, skipped: {e}", + l.id, l.name + ); + }; + if queries::own_coin_in_tx(conn, profile, &owner.0, payment)? { + if let Some(coin) = client.get_coin(&owner.0, owner.1).await? { + let Some(owner_at) = sell::CoinAt::of_coin(&coin) else { + return Err(AppError::Rpc(format!( + "node did not report the address or covenant of coin {}:{}", + owner.0, owner.1 + ))); + }; + if at.holds(owner_at, COV_TRANSFER, None) { + if sell::commitment_is_ours(owner_at.items, network, &own)? { + return Ok(vec![]); + } + // hsd names a coin the owner only once its block is + // connected: an owner coin shown in the mempool is not a + // mined purchase, so no verdict. + let Some(height) = coin.mined_height()? else { + return Ok(vec![]); + }; + match purchase_found(client, &owner.0, Some(height), &p).await { + Ok(Some((Some(_), spent))) => { + found.push(Sale::Mined { + txid: owner.0.clone(), + lock: spent, + }); + if first_only { + return Ok(found); + } + } + // Not a purchase of our lock coin (or a view that + // disagrees with the mined owner): look at the leads. + Ok(_) => {} + Err(e) => unreadable(&owner.0, e), + } + } + } + } + for (txid, seen_at) in queries::own_coins_at(conn, profile, payment)? { + let sale = match purchase_found(client, &txid, seen_at, &p).await { + Ok(Some((Some(_), spent))) => Sale::Mined { txid, lock: spent }, + Ok(Some((None, spent))) if first_only => Sale::Pending { txid, lock: spent }, + Ok(_) => continue, + Err(e) => { + unreadable(&txid, e); + continue; + } + }; + if first_only { + return Ok(vec![sale]); + } + if !found.contains(&sale) { + found.push(sale); + } + } + Ok(found) +} + /// Whether a Finalizing listing's FINALIZE draft is dead: it can no longer /// be mined (`failed`, `dropped`, or its row gone; not /// [`queries::draft_alive`]). The SQL writes ask the same of the database diff --git a/src-tauri/src/tests/live_node_it.rs b/src-tauri/src/tests/live_node_it.rs index 2f607b0..9a947ea 100644 --- a/src-tauri/src/tests/live_node_it.rs +++ b/src-tauri/src/tests/live_node_it.rs @@ -5842,17 +5842,16 @@ async fn shakedex_cancel_transfer_aborts_the_listing_on_chain() { assert_eq!(listing_state(&app, &listing_id), ListingState::Aborted); } -/// Run the R19 abort step (`shakedex_jobs::refresh_listings_before_lock_with_client`) -/// on the app's database against the live node, as `run_sync_steps` does. The +/// Run the listing step (`shakedex_jobs::refresh_listings_with_client`, whose +/// before-lock job is R19's abort) on the app's database against the live node, as `run_sync_steps` does. The /// connection is taken out of the app for the call, so no lock is held across -/// an await (same pattern as `shakedex_sell_tests::run_abort_job`). +/// an await (same pattern as `shakedex_sell_tests::run_listing_step`). async fn abort_job(app: &tauri::App, cl: &NodeRpcClient) { let conn = std::mem::replace( &mut *app.state::().db.lock().unwrap(), rusqlite::Connection::open_in_memory().unwrap(), ); - let res = - crate::shakedex_jobs::refresh_listings_before_lock_with_client(&conn, cl, PROFILE).await; + let res = crate::shakedex_jobs::refresh_listings_with_client(&conn, cl, PROFILE).await; *app.state::().db.lock().unwrap() = conn; res.expect("abort job runs"); } @@ -5958,17 +5957,17 @@ fn hsw_rpc(api_key: &str, args: &[&str]) -> String { .to_string() } -/// Run both listing jobs on the app's database against the live node, as +/// Run the listing step's body (`shakedex_jobs::refresh_listings_with_client`, +/// both listing jobs) on the app's database against the live node, as /// `run_sync_steps` does (connection swapped out, as in `abort_job`). async fn listing_jobs(app: &tauri::App, cl: &NodeRpcClient) { - abort_job(app, cl).await; let conn = std::mem::replace( &mut *app.state::().db.lock().unwrap(), rusqlite::Connection::open_in_memory().unwrap(), ); - let res = crate::shakedex_jobs::refresh_lock_finalize_with_client(&conn, cl, PROFILE).await; + let res = crate::shakedex_jobs::refresh_listings_with_client(&conn, cl, PROFILE).await; *app.state::().db.lock().unwrap() = conn; - res.expect("finalize job runs"); + res.expect("listing jobs run"); } /// A fresh name locked on chain and its lockup mined out; the listing @@ -6162,10 +6161,11 @@ async fn shakedex_namehold_buy_now_is_bought_by_the_cli() { /// R19 on hsd: a reorg takes the FINALIZE into the lock out of its block, /// and the listing follows it. hsd's `invalidateblock` empties the mempool -/// (`reset`), so while the FINALIZE is nowhere the listing stays Listed (no -/// verdict from a 404); handed back to the node, as a real reorg's mempool -/// would hold it, it is a coin in the mempool and the listing is Finalizing; -/// mined again, Listed. Every step is read from hsd before the jobs run. +/// (`reset`), so while the FINALIZE is nowhere the lock TRANSFER it spent is +/// a coin again and the listing is Finalizing (the 404 for the lock coin is +/// not alone); handed back to the node, as a real reorg's mempool would hold +/// it, it is a coin in the mempool and the listing stays Finalizing; mined +/// again, Listed. Every step is read from hsd before the jobs run. #[tokio::test] async fn shakedex_listing_follows_reorg_of_its_lock_finalize() { let Some((url, key)) = shakedex_node_env("shakedex_listing_follows_reorg_of_its_lock_finalize") @@ -6195,11 +6195,27 @@ async fn shakedex_listing_follows_reorg_of_its_lock_finalize() { lock_coin(cl.clone()).await.is_none(), "invalidateblock leaves the FINALIZE nowhere" ); + let transfer = { + let state = app.state::(); + let c = state.db.lock().unwrap(); + db::queries::get_shakedex_listing(&c, &id) + .unwrap() + .unwrap() + .lock_transfer_txid + .unwrap() + }; + assert!( + cl.get_coin(&transfer, 0) + .await + .expect("coin lookup") + .is_some(), + "the lock TRANSFER is a coin again: the FINALIZE is in no block and no mempool" + ); listing_jobs(&app, &cl).await; assert_eq!( listing_state(&app, &id), - ListingState::Listed, - "a 404 alone is no verdict" + ListingState::Finalizing, + "the FINALIZE into the lock is nowhere" ); let fin = draft_status(&app, s.finalize_draft_id.as_deref().expect("draft")); @@ -6213,6 +6229,9 @@ async fn shakedex_listing_follows_reorg_of_its_lock_finalize() { assert_eq!(listing_state(&app, &id), ListingState::Finalizing); cl.generate_to_address(1, &addr).await.expect("mine"); + // Only clears the invalid mark (hsd's `reconsiderblock` is + // `chain.removeInvalid`, no reorg): the chain stays on the block just + // mined, and the node keeps no invalid block for later tests. cl.reconsider_block(&block).await.expect("reconsider"); let again = lock_coin(cl.clone()).await.expect("the lock coin"); assert!( @@ -6295,6 +6314,579 @@ async fn shakedex_finalize_from_another_device_is_a_restored_lock() { let _ = std::fs::remove_file(©); } +/// R22 and R30 on hsd: the shakedex CLI buys our Buy Now (`fill` mines it). +/// On hsd first: the name's owner is the fill's TRANSFER out of our lock, +/// and the fill pays our payment address the price; after a sync the listing +/// is Sold with the fill's txid. Then a reorg of the purchase: the fill's +/// block invalidated (hsd empties its mempool) leaves the lock coin a coin +/// again and the listing Listed; the fill handed back is in the mempool, the +/// owner still the lock coin, and the listing SalePending; mined again, Sold. +#[tokio::test] +async fn shakedex_namehold_listing_sold_to_the_cli_is_sold() { + let Some((url, key, cli)) = shakedex_env("shakedex_namehold_listing_sold_to_the_cli_is_sold") + else { + return; + }; + let (app, cl, addr, name, id) = ready_to_finalize_on_chain(&url, &key, "nhsold").await; + let s = finalize_and_sign_on_chain(&app, &cl, &addr, &id, "3", 1).await; + let lock_txid = s.lock_txid.clone().expect("lock txid"); + let lock_vout = u32::try_from(s.lock_vout.expect("lock vout")).unwrap(); + let pay = s.payment_address.clone().expect("payment address"); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Listed); + let file = { + let state = app.state::(); + let conn = state.db.lock().unwrap(); + crate::commands::shakedex::export_listing_file_from_conn(&conn, PROFILE, &id) + .expect("export") + }; + let cli_addr = hsw_rpc(&key, &["getnewaddress"]); + cl.generate_to_address(4, &cli_addr) + .await + .expect("fund the CLI wallet"); + let maturity = u32::try_from(NET.coinbase_maturity()).unwrap(); + cl.generate_to_address(maturity, &addr) + .await + .expect("mature its coinbases"); + cli.fill(&CliListing::new(file)); + + let (fill_txid, fill_vout) = name_owner(&cl, &name).await; + assert_ne!(fill_txid, lock_txid, "the name moved out of the lock"); + assert!( + cl.get_coin(&lock_txid, lock_vout) + .await + .expect("coin") + .is_none(), + "the lock coin is spent" + ); + let lock = crate::noncustodial::shakedex::lock_key::derive_lock_key( + &master(), + NET, + test_acct(), + &name, + ) + .unwrap(); + let out = cl + .get_coin(&fill_txid, fill_vout) + .await + .expect("coin") + .expect("the fill's TRANSFER"); + assert_eq!(out.address.as_deref(), Some(lock.address.as_str())); + assert_eq!( + out.covenant.as_ref().expect("covenant").kind, + crate::noncustodial::sync::COV_TRANSFER + ); + let block_height = out.mined_height().unwrap().expect("mined"); + assert_eq!( + paid_to(&cl, &fill_txid, &pay).await, + 3_000_000, + "our payment address got the price" + ); + + let get = |app: &tauri::App| { + let state = app.state::(); + let c = state.db.lock().unwrap(); + db::queries::get_shakedex_listing(&c, &id).unwrap().unwrap() + }; + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + let l = get(&app); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(fill_txid.as_str())) + ); + + // The reorg of the purchase. + let fill_hex = cl.get_tx_by_hash(&fill_txid).await.expect("tx")["hex"] + .as_str() + .expect("hex") + .to_string(); + let block = cl.get_block_hash(block_height).await.expect("blockhash"); + cl.invalidate_block(&block).await.expect("invalidate"); + assert!( + cl.get_coin(&lock_txid, lock_vout) + .await + .expect("coin") + .is_some(), + "the lock coin is a coin again" + ); + assert_eq!( + name_owner(&cl, &name).await, + (lock_txid.clone(), lock_vout), + "the owner is the lock coin again" + ); + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + let l = get(&app); + assert_eq!( + (l.state, l.sold_txid), + (ListingState::Listed, None), + "the purchase is nowhere" + ); + + cl.send_raw_transaction(&fill_hex) + .await + .expect("hand the fill back"); + wait_until_node_has(&cl, &fill_txid).await; + assert!( + cl.get_coin(&lock_txid, lock_vout) + .await + .expect("coin") + .is_none(), + "the lock coin is spent in the mempool" + ); + assert_eq!( + name_owner(&cl, &name).await, + (lock_txid.clone(), lock_vout), + "the owner moves only on a block" + ); + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + let l = get(&app); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::SalePending, Some(fill_txid.as_str())) + ); + + cl.generate_to_address(1, &addr).await.expect("mine"); + // Only clears the invalid mark (hsd's `reconsiderblock` is + // `chain.removeInvalid`, no reorg): the fill is mined again by the block + // just mined, and the node keeps no invalid block for later tests. + cl.reconsider_block(&block).await.expect("reconsider"); + assert_eq!(name_owner(&cl, &name).await.0, fill_txid, "mined again"); + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + let l = get(&app); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(fill_txid.as_str())) + ); +} + +/// A Listed lock ends with its name's registration. The FINALIZE into the +/// lock renewed the name (hsd `ns.setRenewal`), so hsd's `renewalPeriodEnd` +/// is that block plus the renewal window. One block before it the listing +/// stays Listed; at it hsd reports no live state (`info: null`, `isExpired` +/// at `height >= renewal + renewalWindow`) while the lock coin is still a +/// coin, and the listing is Expired. The blocks are mined to an address of +/// nobody's and taken back by a [`RewindOnDrop`] guard, even on a panic. +#[tokio::test] +async fn shakedex_listed_lock_expires_with_its_name() { + let Some((url, key)) = shakedex_node_env("shakedex_listed_lock_expires_with_its_name") else { + return; + }; + let (app, cl, addr, name, id) = ready_to_finalize_on_chain(&url, &key, "nhexpire").await; + let s = finalize_and_sign_on_chain(&app, &cl, &addr, &id, "3", 1).await; + let lock_txid = s.lock_txid.clone().expect("lock txid"); + let lock_vout = u32::try_from(s.lock_vout.expect("lock vout")).unwrap(); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Listed); + + let finalized_at = cl + .get_coin(&lock_txid, lock_vout) + .await + .expect("coin") + .expect("the lock coin") + .mined_height() + .unwrap() + .expect("mined"); + let info = cl.get_name_info(&name).await.expect("name info"); + assert_eq!(info["info"]["claimed"], 0, "{info}"); + let end = info["info"]["stats"]["renewalPeriodEnd"] + .as_i64() + .expect("hsd's renewalPeriodEnd"); + assert_eq!( + end, + finalized_at + i64::from(NET.name_params().renewal_window), + "the FINALIZE into the lock renewed the name" + ); + let tip = cl.get_blockchain_info().await.expect("info").blocks; + let rewind = RewindOnDrop::new(&url, &key, tip); + + mine_to(&cl, &burn_addr(), end - 1).await; + let before = cl.get_name_info(&name).await.expect("name info"); + assert!(!before["info"].is_null(), "still live: {before}"); + listing_jobs(&app, &cl).await; + assert_eq!( + listing_state(&app, &id), + ListingState::Listed, + "one block before the end" + ); + + cl.generate_to_address(1, &burn_addr()).await.expect("mine"); + let after = cl.get_name_info(&name).await.expect("name info"); + assert!(after["info"].is_null(), "expired on hsd: {after}"); + assert!( + cl.get_coin(&lock_txid, lock_vout) + .await + .expect("coin") + .is_some(), + "the lock coin is still a coin" + ); + listing_jobs(&app, &cl).await; + let expired = { + let state = app.state::(); + let conn = state.db.lock().unwrap(); + db::queries::get_shakedex_listing(&conn, &id) + .unwrap() + .unwrap() + }; + assert_eq!( + ( + expired.state, + expired.lock_txid.as_deref(), + expired.lock_vout + ), + ( + ListingState::Expired, + Some(lock_txid.as_str()), + Some(i64::from(lock_vout)) + ), + "Expired, keeping its lock outpoint" + ); + + rewind.rewind(&cl).await; + assert_eq!( + cl.get_blockchain_info().await.expect("info").blocks, + tip, + "the chain is back" + ); +} + +/// R22 and Review Focus 4 on a node without `--index-tx`: Namehold sells, a +/// second Namehold profile buys the exported file. On hsd: the mined +/// purchase is not found by its txid (no index), the name's owner is its +/// TRANSFER out of our lock, and our payment coin (found with `GET /coin`) +/// is the price. After a sync the listing is Sold through the owner. A copy +/// of the seller's database made while the listing was Listed (a second +/// device) syncs only after the buyer has finalized: the owner is then the +/// buyer's FINALIZE and the purchase's TRANSFER is spent, and the copy is +/// Sold too, from the block its payment coin was mined in. +#[tokio::test] +async fn live_noindex_listing_sold_is_detected() { + let Some((url, key)) = noindex_env() else { + eprintln!("skip live_noindex_listing_sold_is_detected: set HNS_IT_NOINDEX_NODE_URL"); + return; + }; + let (app, cl, addr, name, id) = ready_to_finalize_on_chain(&url, &key, "nhnoidx").await; + let s = finalize_and_sign_on_chain(&app, &cl, &addr, &id, "3", 1).await; + let lock_txid = s.lock_txid.clone().expect("lock txid"); + let pay = s.payment_address.clone().expect("payment address"); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Listed); + let e = cl + .get_raw_transaction(&lock_txid) + .await + .expect_err("no transaction index"); + assert!(crate::noncustodial::rpc::is_tx_not_found(&e), "{e}"); + let (file, copy) = { + let state = app.state::(); + let conn = state.db.lock().unwrap(); + let file = crate::commands::shakedex::export_listing_file_from_conn(&conn, PROFILE, &id) + .expect("export"); + let copy = std::env::temp_dir().join(format!( + "namehold_live_noindex_other_{}_{}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + conn.execute("VACUUM INTO ?1", [copy.to_str().unwrap()]) + .expect("copy the database"); + (file, copy) + }; + + let b = ShakedexBuyer::new(&url, &key).await; + let listing = CliListing::new(file); + sync_wallet_state(b.app.state(), None).await.expect("sync"); + let draft = b.sign_purchase(&listing).await; + let bc = broadcast_tx_draft(b.app.state(), draft.id.clone()) + .await + .expect("broadcast"); + assert_eq!(bc.status, "broadcasted"); + settle(&b.app, &b.cl, &b.addr, &draft.id).await; + let buy = bc.txid.clone(); + assert!(crate::noncustodial::rpc::is_tx_not_found( + &cl.get_raw_transaction(&buy) + .await + .expect_err("mined, no index") + )); + assert_eq!( + name_owner(&cl, &name).await, + (buy.clone(), 0), + "the owner is the purchase's TRANSFER" + ); + let mut ours = None; + for vout in 0..4 { + if let Some(c) = cl.get_coin(&buy, vout).await.expect("coin") { + if c.address.as_deref() == Some(pay.as_str()) { + ours = Some(c); + } + } + } + assert_eq!(ours.expect("our payment coin").value, 3_000_000); + + let get = |app: &tauri::App| { + let state = app.state::(); + let c = state.db.lock().unwrap(); + db::queries::get_shakedex_listing(&c, &id).unwrap().unwrap() + }; + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + let l = get(&app); + // "Through the owner": the owner is the purchase's TRANSFER here, so + // `find_purchases` reads the purchase at the owner coin's height before + // any payment-coin lead (the copy below, synced after the buyer's + // FINALIZE, can only take the lead path). + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(buy.as_str())), + "through the owner" + ); + + // The buyer finalizes; only then does the copy sync. + b.cl.generate_to_address(NET.name_params().transfer_lockup, &b.addr) + .await + .expect("mine the lockup"); + b.refresh().await; + let p = b.purchase(&name); + let fin = crate::commands::shakedex::shakedex_build_purchase_finalize_draft( + b.app.state(), + p.id.clone(), + Some(1), + ) + .await + .expect("build finalize draft"); + broadcast_only(&b.app, &fin.id).await; + settle(&b.app, &b.cl, &b.addr, &fin.id).await; + assert_eq!( + owner_coin_address(&cl, &name).await.as_deref(), + Some(p.destination_address.as_str()) + ); + assert!( + cl.get_coin(&buy, 0).await.expect("coin").is_none(), + "the purchase's TRANSFER is spent" + ); + + let other_conn = rusqlite::Connection::open(©).unwrap(); + other_conn + .execute_batch("PRAGMA foreign_keys = ON;") + .unwrap(); + let other = app_with(other_conn); + assert_eq!( + get(&other).state, + ListingState::Listed, + "the copy never saw the sale" + ); + sync_wallet_state(other.state(), None).await.expect("sync"); + listing_jobs(&other, &cl).await; + let l = get(&other); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(buy.as_str())), + "from the block" + ); + drop(other); + let _ = std::fs::remove_file(©); +} + +/// R32 on hsd: a fresh profile from the same recovery phrase and account +/// (none of the first one's database) restores the Listed lock by name: a +/// Restored lock with the outpoint hsd names as the owner, a FINALIZE at the +/// lock address derived from the phrase; a second restore is refused; the +/// first device's saved file, imported after a sync of the fresh profile, +/// makes it Listed with that file's payment address, and the listing jobs +/// keep it Listed. +#[tokio::test] +async fn shakedex_restore_lock_by_name_after_a_fresh_profile() { + let Some((url, key)) = shakedex_node_env("shakedex_restore_lock_by_name_after_a_fresh_profile") + else { + return; + }; + let (app, cl, addr, name, id) = ready_to_finalize_on_chain(&url, &key, "nhrestore").await; + let s = finalize_and_sign_on_chain(&app, &cl, &addr, &id, "3", 1).await; + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Listed); + let file = { + let state = app.state::(); + let conn = state.db.lock().unwrap(); + crate::commands::shakedex::export_listing_file_from_conn(&conn, PROFILE, &id) + .expect("export") + }; + + // The payment address's receive index on the first device: a restore + // derives `[0, gap)` per branch (`provision_addresses`, the default gap), + // and only an address in that window is one of the fresh profile's. + let pay_index: i64 = { + let state = app.state::(); + let c = state.db.lock().unwrap(); + c.query_row( + "SELECT child_index FROM derived_addresses + WHERE wallet_profile_id = ?1 AND branch = 0 AND address = ?2", + params![ + PROFILE, + s.payment_address.as_deref().expect("payment address") + ], + |r| r.get(0), + ) + .expect("the payment address is a derived receive address") + }; + let gap = crate::commands::secure_wallet::gap_limit(&std::collections::HashMap::new()); + assert!( + pay_index < i64::from(gap), + "receive index {pay_index} lies past the restore window of {gap}" + ); + let fresh_conn = seeded_conn_acct(&url, &key, test_acct()); + crate::commands::secure_wallet::provision_addresses( + &fresh_conn, + PROFILE, + NET, + &account_xpub_at(test_acct()), + gap, + ) + .expect("the restore's address window"); + let fresh = app_with(fresh_conn); + sync_wallet_state(fresh.state(), None).await.expect("sync"); + unlock(&fresh); + assert!(open_listing(&fresh, &name).is_none()); + let r = crate::commands::shakedex::shakedex_restore_lock(fresh.state(), name.clone()) + .await + .expect("restore"); + assert_eq!(r.state, ListingState::Restored); + let owner = name_owner(&cl, &name).await; + assert_eq!( + ( + r.lock_txid.clone().unwrap(), + u32::try_from(r.lock_vout.unwrap()).unwrap() + ), + owner + ); + assert_eq!(r.lock_txid, s.lock_txid); + let lock = crate::noncustodial::shakedex::lock_key::derive_lock_key( + &master(), + NET, + test_acct(), + &name, + ) + .unwrap(); + let coin = cl + .get_coin(&owner.0, owner.1) + .await + .expect("coin") + .expect("lock coin"); + assert_eq!(coin.address.as_deref(), Some(lock.address.as_str())); + assert_eq!( + coin.covenant.as_ref().expect("covenant").kind, + crate::noncustodial::sync::COV_FINALIZE + ); + expect_err( + crate::commands::shakedex::shakedex_restore_lock(fresh.state(), name.clone()).await, + "already tracked", + ); + + let up = crate::commands::shakedex::shakedex_import_own_listing_file(fresh.state(), file) + .await + .expect("import"); + assert_eq!(up.state, ListingState::Listed); + assert_eq!(up.payment_address, s.payment_address); + listing_jobs(&fresh, &cl).await; + assert_eq!( + open_listing(&fresh, &name).unwrap().state, + ListingState::Listed + ); +} + +/// R22 for a lock restored by name (R32), which knows no payment address: a +/// fresh profile from the same phrase restores the Listed lock by name, and +/// a Namehold buyer buys the first device's file. While the purchase is in +/// the mempool the lock coin is hsd's 404 and the owner is still the lock +/// coin: no verdict. Once it is mined, the owner is its TRANSFER out of the +/// lock, linked from the stored lock outpoint, committing to the buyer's +/// address: Sold with the purchase's txid, with no payment address known. +#[tokio::test] +async fn shakedex_restored_lock_by_name_bought_is_sold() { + let Some((url, key)) = shakedex_node_env("shakedex_restored_lock_by_name_bought_is_sold") + else { + return; + }; + let (app, cl, addr, name, id) = ready_to_finalize_on_chain(&url, &key, "nhrsold").await; + let s = finalize_and_sign_on_chain(&app, &cl, &addr, &id, "3", 1).await; + let lock = ( + s.lock_txid.clone().expect("lock txid"), + u32::try_from(s.lock_vout.expect("lock vout")).unwrap(), + ); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Listed); + let file = { + let state = app.state::(); + let conn = state.db.lock().unwrap(); + crate::commands::shakedex::export_listing_file_from_conn(&conn, PROFILE, &id) + .expect("export") + }; + + let fresh = app_with(seeded_conn_acct(&url, &key, test_acct())); + sync_wallet_state(fresh.state(), None).await.expect("sync"); + unlock(&fresh); + let r = crate::commands::shakedex::shakedex_restore_lock(fresh.state(), name.clone()) + .await + .expect("restore"); + assert_eq!( + (r.state, r.payment_address.as_deref()), + (ListingState::Restored, None) + ); + let restored = |app: &tauri::App| { + let state = app.state::(); + let c = state.db.lock().unwrap(); + db::queries::get_shakedex_listing(&c, &r.id) + .unwrap() + .unwrap() + }; + + let b = ShakedexBuyer::new(&url, &key).await; + sync_wallet_state(b.app.state(), None).await.expect("sync"); + let draft = b.sign_purchase(&CliListing::new(file)).await; + let bc = broadcast_tx_draft(b.app.state(), draft.id.clone()) + .await + .expect("broadcast"); + assert_eq!(bc.status, "broadcasted"); + let buy = bc.txid.clone(); + wait_until_node_has(&cl, &buy).await; + assert!( + cl.get_coin(&lock.0, lock.1).await.expect("coin").is_none(), + "the lock coin is spent in the mempool" + ); + assert_eq!( + name_owner(&cl, &name).await, + lock, + "the owner moves only on a block" + ); + listing_jobs(&fresh, &cl).await; + let l = restored(&fresh); + assert_eq!( + (l.state, l.sold_txid), + (ListingState::Restored, None), + "mempool" + ); + + settle(&b.app, &b.cl, &b.addr, &draft.id).await; + assert_eq!( + name_owner(&cl, &name).await, + (buy.clone(), 0), + "the owner is the purchase's TRANSFER" + ); + listing_jobs(&fresh, &cl).await; + let l = restored(&fresh); + assert_eq!( + (l.state, l.sold_txid.as_deref(), l.payment_address), + (ListingState::Sold, Some(buy.as_str()), None) + ); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(lock.0.as_str()), Some(i64::from(lock.1))) + ); +} + /// R4 on hsd: the FINALIZE into the lock, built at 20 doos/vbyte (above the /// 5 doos/vbyte floor), pays the fee its summary shows at the rate hsd /// reports for it. diff --git a/src-tauri/src/tests/mock_node_rpc.rs b/src-tauri/src/tests/mock_node_rpc.rs index 0df22ba..3115db3 100644 --- a/src-tauri/src/tests/mock_node_rpc.rs +++ b/src-tauri/src/tests/mock_node_rpc.rs @@ -24,6 +24,8 @@ use crate::noncustodial::rpc::{BlockchainInfo, ChainSource, NodeCoin}; type ResponseFn = Box Result + Send + Sync>; /// [`ResponseFn`] for a method whose reply depends on its two arguments. type ResponseFn2 = Box Result + Send + Sync>; +/// [`ResponseFn`] for a method whose reply depends on its one argument. +type ResponseFn1 = Box Result + Send + Sync>; /// One recorded invocation of a [`MockNodeRpc`] method, capturing the method /// name and its salient argument(s). Lets a test assert not just that the code @@ -70,9 +72,9 @@ pub struct MockNodeRpc { get_coin: ResponseFn2>, txs_by_address: ResponseFn>, raw_transaction: ResponseFn, - tx_by_hash: ResponseFn, - block_hash: ResponseFn, - block: ResponseFn, + tx_by_hash: ResponseFn1, + block_hash: ResponseFn1, + block: ResponseFn1, generate_to_address: ResponseFn, stop: ResponseFn<()>, send_raw_transaction: ResponseFn, @@ -102,9 +104,9 @@ impl MockNodeRpc { get_coin: Box::new(|_, _| Err(AppError::Rpc("not configured".to_string()))), txs_by_address: err("not configured"), raw_transaction: err("not configured"), - tx_by_hash: err("not configured"), - block_hash: err("not configured"), - block: err("not configured"), + tx_by_hash: Box::new(|_| Err(AppError::Rpc("not configured".to_string()))), + block_hash: Box::new(|_| Err(AppError::Rpc("not configured".to_string()))), + block: Box::new(|_| Err(AppError::Rpc("not configured".to_string()))), generate_to_address: err("not configured"), stop: Box::new(|| Ok(())), send_raw_transaction: err("not configured"), @@ -143,29 +145,54 @@ impl MockNodeRpc { } pub fn with_tx_by_hash(mut self, v: serde_json::Value) -> Self { - self.tx_by_hash = Box::new(move || Ok(v.clone())); + self.tx_by_hash = Box::new(move |_| Ok(v.clone())); + self + } + /// `GET /tx/:hash` answered per txid by `f` (`Value::Null`: hsd's + /// not-found). + pub fn with_tx_by_hash_fn( + mut self, + f: impl Fn(String) -> Result + Send + Sync + 'static, + ) -> Self { + self.tx_by_hash = Box::new(f); self } pub fn with_tx_by_hash_err(mut self, msg: &'static str) -> Self { - self.tx_by_hash = Box::new(move || Err(AppError::Rpc(msg.to_string()))); + self.tx_by_hash = Box::new(move |_| Err(AppError::Rpc(msg.to_string()))); self } pub fn with_block_hash(mut self, hash: String) -> Self { - self.block_hash = Box::new(move || Ok(hash.clone())); + self.block_hash = Box::new(move |_| Ok(hash.clone())); + self + } + /// `getblockhash` answered per height by `f`. + pub fn with_block_hash_fn( + mut self, + f: impl Fn(i64) -> Result + Send + Sync + 'static, + ) -> Self { + self.block_hash = Box::new(f); self } pub fn with_block_hash_err(mut self, msg: &'static str) -> Self { - self.block_hash = Box::new(move || Err(AppError::Rpc(msg.to_string()))); + self.block_hash = Box::new(move |_| Err(AppError::Rpc(msg.to_string()))); self } pub fn with_block(mut self, v: serde_json::Value) -> Self { - self.block = Box::new(move || Ok(v.clone())); + self.block = Box::new(move |_| Ok(v.clone())); + self + } + /// `getblock` answered per block hash by `f`. + pub fn with_block_fn( + mut self, + f: impl Fn(String) -> Result + Send + Sync + 'static, + ) -> Self { + self.block = Box::new(f); self } pub fn with_block_err(mut self, msg: &'static str) -> Self { - self.block = Box::new(move || Err(AppError::Rpc(msg.to_string()))); + self.block = Box::new(move |_| Err(AppError::Rpc(msg.to_string()))); self } @@ -397,17 +424,17 @@ impl NodeRpc for MockNodeRpc { async fn get_tx_by_hash(&self, txid: &str) -> Result { self.record(RpcCall::TxByHash(txid.to_string())); - (self.tx_by_hash)() + (self.tx_by_hash)(txid.to_string()) } async fn get_block_hash(&self, height: i64) -> Result { self.record(RpcCall::BlockHash(height)); - (self.block_hash)() + (self.block_hash)(height) } async fn get_block(&self, hash: &str) -> Result { self.record(RpcCall::Block(hash.to_string())); - (self.block)() + (self.block)(hash.to_string()) } async fn generate_to_address( diff --git a/src-tauri/src/tests/mod.rs b/src-tauri/src/tests/mod.rs index 3d698eb..8253f8d 100644 --- a/src-tauri/src/tests/mod.rs +++ b/src-tauri/src/tests/mod.rs @@ -77,6 +77,7 @@ mod shakedex_cmd_tests; mod shakedex_finalize_tests; mod shakedex_layering_tests; mod shakedex_listing_file_tests; +mod shakedex_listing_state_tests; mod shakedex_lock_key_tests; mod shakedex_purchase_state_tests; mod shakedex_purchase_store_tests; diff --git a/src-tauri/src/tests/read_cmd_tests.rs b/src-tauri/src/tests/read_cmd_tests.rs index 6a2e9a2..027cce7 100644 --- a/src-tauri/src/tests/read_cmd_tests.rs +++ b/src-tauri/src/tests/read_cmd_tests.rs @@ -7,6 +7,7 @@ //! addresses, and transaction reads. use rusqlite::params; +use serde_json::{json, Value}; use tauri::test::{mock_builder, mock_context, noop_assets}; use tauri::Manager; @@ -3448,3 +3449,176 @@ async fn other_profiles_purchases_are_not_listed() { let val = read_names(app.state(), None).await.unwrap(); assert_eq!(val, serde_json::json!([])); } + +fn add_listing( + conn: &rusqlite::Connection, + id: &str, + name: &str, + state: db::queries::ListingState, + lock_draft: Option<&str>, +) { + let key = crate::noncustodial::shakedex::lock_key::derive_lock_key( + &crate::noncustodial::hd::ExtendedPrivKey::from_seed(&[7u8; 64]).unwrap(), + crate::noncustodial::network::Network::Regtest, + 0, + name, + ) + .unwrap(); + db::queries::insert_shakedex_listing( + conn, + &db::queries::ShakedexListing { + id: id.into(), + wallet_profile_id: "W1".into(), + name: name.into(), + mode: db::queries::ListingMode::BuyNow, + state, + lock_pubkey_hex: hex::encode(key.pubkey), + lock_transfer_draft_id: lock_draft.map(Into::into), + lock_finalize_draft_id: None, + lock_transfer_txid: None, + lock_txid: None, + lock_vout: None, + payment_address: None, + cancel_address: None, + cancel_child_index: None, + steps_json: "[]".into(), + listing_file_json: None, + publish: false, + market_status: None, + market_retry_at: None, + expires_at: None, + abort_draft_id: None, + abort_txid: None, + sold_txid: None, + cancel_txid: None, + created_at: String::new(), + updated_at: String::new(), + }, + ) + .unwrap(); +} + +/// R19: a name's listing state is on its Owned Names row. alpha, still at +/// our address while Locking, carries the blocks left in the lockup (its +/// lock TRANSFER confirmed at 1995, sync height 2000: regtest lockup 10, +/// judged at tip + 1 → 4); bravo, in our lock (no longer a coin at our +/// address), gets a row of its own at the lock address; charlie, sold, shows +/// Sold; delta (Aborted) and echo (Locking with a dead lock draft) show none. +#[tokio::test] +async fn locked_name_listed_with_its_listing_state() { + use db::queries::ListingState as S; + let conn = empty_db(); + add_profile(&conn, "W1", "regtest"); + db::queries::set_active_profile(&conn, "W1").unwrap(); + crate::noncustodial::sync::set_sync_cursor(&conn, "W1", 2_000).unwrap(); + add_owned_name(&conn, "W1", "alpha", "txA"); + add_owned_name(&conn, "W1", "echo", "txE"); + for (id, status) in [("lockA", "confirmed"), ("lockE", "dropped")] { + db::queries::insert_tx_draft(&conn, id, "W1", "shakedex_lock", "00", "{}", "{}").unwrap(); + conn.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = ?2", + params![status, id], + ) + .unwrap(); + } + db::queries::update_tx_draft_confirmation(&conn, "lockA", 1_995, None).unwrap(); + add_listing(&conn, "la", "alpha", S::Locking, Some("lockA")); + add_listing(&conn, "lb", "bravo", S::Listed, None); + add_listing(&conn, "lc", "charlie", S::Sold, None); + add_listing(&conn, "ld", "delta", S::Aborted, None); + add_listing(&conn, "le", "echo", S::Locking, Some("lockE")); + let bravo_lock = { + let pk: [u8; 33] = hex::decode( + db::queries::get_shakedex_listing(&conn, "lb") + .unwrap() + .unwrap() + .lock_pubkey_hex, + ) + .unwrap() + .try_into() + .unwrap(); + crate::noncustodial::shakedex::script::lock_address( + crate::noncustodial::network::Network::Regtest, + &pk, + ) + .unwrap() + }; + let app = app_with(conn); + let val = read_names(app.state(), None).await.unwrap(); + let row = |n: &str| { + val.as_array() + .unwrap() + .iter() + .find(|v| v["name"] == n) + .cloned() + }; + let alpha = row("alpha").expect("alpha"); + assert_eq!( + alpha["listing"], + json!({ "listingId": "la", "state": "locking", "blocksUntilFinalize": 4 }) + ); + let bravo = row("bravo").expect("bravo, in our lock"); + assert_eq!(bravo["listing"]["state"], "listed"); + assert_eq!(bravo["listing"]["blocksUntilFinalize"], Value::Null); + assert_eq!(bravo["owner_address"], bravo_lock); + assert_eq!(row("charlie").expect("charlie")["listing"]["state"], "sold"); + assert!(row("delta").is_none(), "an aborted listing adds no row"); + assert!( + row("echo").expect("echo").get("listing").is_none(), + "a dead lock does not mark the name" + ); +} + +/// R27: a locked name is not renewed (the lock script allows only TRANSFER +/// and FINALIZE): the Renewals screen leaves out every row with a listing. +#[tokio::test] +async fn renewals_leave_out_a_locked_name() { + let conn = empty_db(); + add_profile(&conn, "W1", "regtest"); + add_listing( + &conn, + "lb", + "bravo", + db::queries::ListingState::Listed, + None, + ); + let resp = crate::commands::read::compute_renewals(&conn, "W1", Some(2_000)).unwrap(); + assert!(resp.names.iter().all(|r| r.name != "bravo")); +} + +/// R27: a name still at our address under a lock TRANSFER that is alive +/// (Locking) is not offered for renewal; one whose lock TRANSFER draft is +/// dead (dropped) holds nothing and is offered as any owned name. +#[tokio::test] +async fn renewals_leave_out_a_name_locking_at_our_address() { + let conn = empty_db(); + add_profile(&conn, "W1", "regtest"); + add_owned_name(&conn, "W1", "alpha", "txA"); + add_owned_name(&conn, "W1", "echo", "txE"); + for (id, status) in [("lockA", "broadcasted"), ("lockE", "dropped")] { + db::queries::insert_tx_draft(&conn, id, "W1", "shakedex_lock", "00", "{}", "{}").unwrap(); + conn.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = ?2", + params![status, id], + ) + .unwrap(); + } + add_listing( + &conn, + "la", + "alpha", + db::queries::ListingState::Locking, + Some("lockA"), + ); + add_listing( + &conn, + "le", + "echo", + db::queries::ListingState::Locking, + Some("lockE"), + ); + let resp = crate::commands::read::compute_renewals(&conn, "W1", Some(2_000)).unwrap(); + let names: Vec<&str> = resp.names.iter().map(|r| r.name.as_str()).collect(); + assert!(!names.contains(&"alpha"), "{names:?}"); + assert!(names.contains(&"echo"), "{names:?}"); +} diff --git a/src-tauri/src/tests/shakedex_listing_state_tests.rs b/src-tauri/src/tests/shakedex_listing_state_tests.rs new file mode 100644 index 0000000..f21bb21 --- /dev/null +++ b/src-tauri/src/tests/shakedex_listing_state_tests.rs @@ -0,0 +1,2067 @@ +//! R22 and the listing's states after the FINALIZE into the lock (T4): the +//! store's guarded writes, and the after-lock job — SalePending and Sold +//! from a purchase of the lock coin found on chain, their reorgs back, +//! Finalizing when the FINALIZE into the lock is nowhere, and Expired. + +use rusqlite::{params, Connection}; +use serde_json::{json, Value}; + +use crate::db::queries::{self, ListingMode, ListingState, ListingWrite, ShakedexListing}; +use crate::noncustodial::address; +use crate::noncustodial::derivation; +use crate::noncustodial::hd::ExtendedPrivKey; +use crate::noncustodial::names; +use crate::noncustodial::network::Network; +use crate::noncustodial::rpc::NodeCoin; +use crate::noncustodial::shakedex::lock_key::derive_lock_key; +use crate::noncustodial::shakedex::sell::LOCK_FINALIZE_ACTION; +use crate::noncustodial::sync::{COV_FINALIZE, COV_TRANSFER}; +use crate::tests::mock_node_rpc::{MockNodeRpc, RpcCall}; +use crate::tests::shakedex_cmd_tests::{seed, seeded, PROFILE}; + +const NET: Network = Network::Regtest; +const NAME: &str = "dexstate"; +const NAME_HEIGHT: u32 = 50; +const TIP: i64 = 3_000; +const PRICE: i64 = 5_000_000; + +fn txid(byte: &str) -> String { + byte.repeat(32) +} + +/// A regtest profile with a listing of NAME in `state`: its lock coin +/// `(f1…, 0)` at the lock address of the seed's key for NAME, its lock +/// TRANSFER `e1…`, a confirmed FINALIZE draft, a listing file, and reserved +/// payment and cancel addresses of ours. +struct Fx { + conn: Connection, + id: String, + lock: String, + lock_txid: String, + lock_vout: u32, + transfer_txid: String, + payment: String, + cancel: String, + buyer: String, +} + +fn fx(state: ListingState) -> Fx { + let conn = seeded("regtest", "mnemonic_hot", "http://127.0.0.1:9"); + let payment = derivation::reserve_receive_address(&conn, PROFILE).unwrap(); + let cancel = derivation::reserve_receive_address(&conn, PROFILE).unwrap(); + let key = derive_lock_key(&ExtendedPrivKey::from_seed(&seed()).unwrap(), NET, 0, NAME).unwrap(); + queries::insert_tx_draft( + &conn, + "fin", + PROFILE, + LOCK_FINALIZE_ACTION, + "00", + "{}", + "{}", + ) + .unwrap(); + queries::update_tx_draft_confirmation(&conn, "fin", TIP - 20, Some(&txid("f1"))).unwrap(); + let l = ShakedexListing { + id: "l1".into(), + wallet_profile_id: PROFILE.into(), + name: NAME.into(), + mode: ListingMode::BuyNow, + state, + lock_pubkey_hex: hex::encode(key.pubkey), + lock_transfer_draft_id: None, + lock_finalize_draft_id: Some("fin".into()), + lock_transfer_txid: Some(txid("e1")), + lock_txid: Some(txid("f1")), + lock_vout: Some(0), + payment_address: Some(payment.address.clone()), + cancel_address: Some(cancel.address.clone()), + cancel_child_index: Some(i64::from(cancel.child_index)), + steps_json: r#"[{"price":5000000,"lockTime":1,"signature":"ab"}]"#.into(), + listing_file_json: Some("{}".into()), + publish: false, + market_status: None, + market_retry_at: None, + expires_at: Some(1_731_536_000), + abort_draft_id: None, + abort_txid: None, + sold_txid: None, + cancel_txid: None, + created_at: String::new(), + updated_at: String::new(), + }; + queries::insert_shakedex_listing(&conn, &l).unwrap(); + Fx { + conn, + id: "l1".into(), + lock: key.address, + lock_txid: txid("f1"), + lock_vout: 0, + transfer_txid: txid("e1"), + payment: payment.address, + cancel: cancel.address, + buyer: address::encode_p2wpkh(NET, &[9; 20]).unwrap(), + } +} + +fn listing(fx: &Fx) -> ShakedexListing { + queries::get_shakedex_listing(&fx.conn, &fx.id) + .unwrap() + .unwrap() +} + +fn set_state(fx: &Fx, state: ListingState) { + fx.conn + .execute( + "UPDATE shakedex_listings SET state = ?1 WHERE id = ?2", + params![state, fx.id], + ) + .unwrap(); +} + +/// Our coin of `txid` at the payment address, as the wallet's sync records +/// it: `height` -1 while it is in the mempool; `spent` once a later sync no +/// longer finds it. +fn paid(fx: &Fx, txid: &str, vout: u32, height: i64, spent: bool) { + fx.conn + .execute( + "INSERT OR REPLACE INTO tracked_utxos + (txid, vout, wallet_profile_id, address, script_pubkey_hex, value_doos, + height, covenant_type, spend_class, spent_by_txid) + VALUES (?1, ?2, ?3, ?4, '00', ?5, ?6, 0, 'liquid_hns', ?7)", + params![ + txid, + vout, + PROFILE, + fx.payment, + PRICE, + height, + spent.then_some("spent") + ], + ) + .unwrap(); +} + +/// Write `w` on the fixture's listing with arguments that satisfy every +/// guard but the state one: its own lock outpoint and key, a file, a lock +/// TRANSFER, and `to` where the write takes a target. `Err` is a target the +/// write refuses. +fn apply(f: &Fx, w: ListingWrite, to: ListingState) -> Result { + let (c, id, b1) = (&f.conn, f.id.as_str(), txid("b1")); + let own = (f.lock_txid.as_str(), f.lock_vout); + match w { + ListingWrite::Ready => queries::mark_listing_ready(c, id), + ListingWrite::LockingAgain => queries::mark_listing_locking_again(c, id), + ListingWrite::Finalize => { + let tx = c.unchecked_transaction().unwrap(); + let n = queries::mark_listing_finalizing_in_tx( + &tx, + id, + &queries::FinalizingListing { + finalize_draft_id: "fin", + lock_txid: &f.lock_txid, + lock_vout: f.lock_vout, + steps_json: "[]", + listing_file_json: "{}", + expires_at: 1, + }, + ); + tx.commit().unwrap(); + n + } + ListingWrite::RevertToReady => queries::revert_listing_to_ready(c, id), + ListingWrite::Listed => queries::mark_listing_listed(c, id), + ListingWrite::FinalizingAgain => queries::mark_listing_finalizing_again(c, id), + ListingWrite::Abort => queries::abort_shakedex_listing(c, id), + ListingWrite::ExpireBeforeLock => queries::expire_shakedex_listing(c, id), + ListingWrite::AdoptElsewhere => { + queries::adopt_lock_finalized_elsewhere(c, id, own.0, own.1) + } + ListingWrite::Unabort => queries::unabort_shakedex_listing(c, id), + ListingWrite::SalePending => queries::mark_listing_sale_pending(c, id, &b1, own), + ListingWrite::Sell => queries::sell_shakedex_listing(c, id, &b1, own), + ListingWrite::ProvenLockSale => { + queries::sell_listing_through_proven_lock(c, id, &b1, (&txid("f2"), 1)) + } + ListingWrite::Resell => queries::resell_sold_listing(c, id, to, &b1, own), + ListingWrite::Unsell => queries::unsell_shakedex_listing(c, id, to), + ListingWrite::ExpireLocked => queries::expire_locked_listing(c, id), + ListingWrite::Unadopt => queries::unadopt_restored_lock(c, id), + ListingWrite::Upgrade => queries::upgrade_restored_lock( + c, + id, + own.0, + own.1, + &listing(f).lock_pubkey_hex, + &queries::UpgradedListing { + mode: ListingMode::BuyNow, + payment_address: &f.payment, + steps_json: "[]", + listing_file_json: "{}", + expires_at: None, + }, + ), + } +} + +/// Every write that moves a listing's state moves exactly the (from, to) +/// pairs below, pinned as literals: the table ([`ListingWrite::transition`]) +/// is checked against them, and each write is run on a listing in every +/// state, toward every state, with every other guard satisfied. +#[test] +fn each_listing_write_moves_exactly_its_transitions() { + use ListingState as S; + let before_lock_end = [S::Locking, S::ReadyToFinalize, S::Finalizing]; + let in_lock = [S::Finalizing, S::Listed, S::SalePending, S::Restored]; + let mut expected: Vec<(ListingWrite, S, S)> = vec![ + (ListingWrite::Ready, S::Locking, S::ReadyToFinalize), + (ListingWrite::LockingAgain, S::ReadyToFinalize, S::Locking), + (ListingWrite::Finalize, S::ReadyToFinalize, S::Finalizing), + ( + ListingWrite::RevertToReady, + S::Finalizing, + S::ReadyToFinalize, + ), + (ListingWrite::Listed, S::Finalizing, S::Listed), + (ListingWrite::FinalizingAgain, S::Listed, S::Finalizing), + (ListingWrite::Unabort, S::Aborted, S::Locking), + (ListingWrite::ProvenLockSale, S::Locking, S::Sold), + (ListingWrite::ProvenLockSale, S::ReadyToFinalize, S::Sold), + (ListingWrite::Resell, S::Sold, S::SalePending), + (ListingWrite::Resell, S::Sold, S::Sold), + (ListingWrite::Unadopt, S::Restored, S::Locking), + (ListingWrite::Upgrade, S::Restored, S::Listed), + ]; + for from in before_lock_end { + expected.push((ListingWrite::Abort, from, S::Aborted)); + expected.push((ListingWrite::ExpireBeforeLock, from, S::Expired)); + expected.push((ListingWrite::AdoptElsewhere, from, S::Restored)); + } + for from in in_lock { + expected.push((ListingWrite::SalePending, from, S::SalePending)); + expected.push((ListingWrite::Sell, from, S::Sold)); + } + for from in [S::SalePending, S::Sold] { + for to in [S::Listed, S::Finalizing, S::Restored] { + expected.push((ListingWrite::Unsell, from, to)); + } + } + for from in [S::Listed, S::SalePending, S::Restored] { + expected.push((ListingWrite::ExpireLocked, from, S::Expired)); + } + let mut table: Vec<(ListingWrite, S, S)> = Vec::new(); + for w in ListingWrite::ALL { + for from in w.from() { + for to in w.to() { + table.push((w, *from, *to)); + } + } + } + let key = |t: &(ListingWrite, S, S)| format!("{:?} {:?} {:?}", t.0, t.1, t.2); + let mut a: Vec = table.iter().map(key).collect(); + let mut b: Vec = expected.iter().map(key).collect(); + a.sort_unstable(); + b.sort_unstable(); + assert_eq!(a, b, "the transition table"); + + // One fixture; each case starts from its listing re-inserted in `from`. + let f = fx(S::Listed); + let base = listing(&f); + for w in ListingWrite::ALL { + let targets: Vec = if w.to().len() > 1 { + S::ALL.to_vec() + } else { + w.to().to_vec() + }; + for from in S::ALL { + for to in &targets { + // A Finalizing listing ends before the lock only once its + // FINALIZE draft is dead (checked below); the proven-lock + // sale needs a listing without an outpoint. + let proven = w == ListingWrite::ProvenLockSale; + let row = ShakedexListing { + state: from, + lock_txid: base.lock_txid.clone().filter(|_| !proven), + lock_vout: base.lock_vout.filter(|_| !proven), + ..base.clone() + }; + f.conn.execute("DELETE FROM shakedex_listings", []).unwrap(); + queries::insert_shakedex_listing(&f.conn, &row).unwrap(); + f.conn + .execute( + "UPDATE wallet_tx_drafts SET status = 'failed' WHERE id = 'fin'", + [], + ) + .unwrap(); + let allowed = expected.contains(&(w, from, *to)); + let n = match apply(&f, w, *to) { + Ok(n) => n, + Err(_) => { + assert!(!w.to().contains(to), "{w:?} {from:?} -> {to:?}: refused"); + 0 + } + }; + assert_eq!(n == 1, allowed, "{w:?} {from:?} -> {to:?}"); + let state = listing(&f).state; + assert_eq!( + state, + if allowed { *to } else { from }, + "{w:?} {from:?} -> {to:?}" + ); + } + } + } + // Ending a listing before the lock takes a Finalizing one only while its + // FINALIZE draft is dead. + for w in [ + ListingWrite::Abort, + ListingWrite::ExpireBeforeLock, + ListingWrite::AdoptElsewhere, + ] { + let f = fx(S::Finalizing); + assert_eq!(apply(&f, w, S::Aborted).unwrap(), 0, "{w:?}: live FINALIZE"); + assert_eq!(listing(&f).state, S::Finalizing); + } +} + +/// A stored lock output index that is not a `u32` is a corrupted row: the +/// after-lock job reads no coin for it and leaves the listing as it is. +#[tokio::test] +async fn a_listing_with_a_corrupted_lock_output_is_left_as_it_is() { + let f = fx(ListingState::Finalizing); + f.conn + .execute("UPDATE shakedex_listings SET lock_vout = -1", []) + .unwrap(); + let mined = lock_coin(&f, TIP - 20); + let rpc = MockNodeRpc::new() + .with_name_info(info((&f.lock_txid, 0))) + .with_get_coin(move |_, _| Ok(Some(mined.clone()))); + run(&f, &rpc).await; + assert_eq!(listing(&f).state, ListingState::Finalizing); +} + +/// One txid case policy: every txid a listing write stores is lowercase, +/// however the caller spelled it, and the writes and lookups compare stored +/// txids exactly with a lowercased argument. +#[test] +fn listing_txids_are_stored_lowercase() { + let upper = |t: &str| t.to_ascii_uppercase(); + let lc = |l: &ShakedexListing| { + [ + l.lock_transfer_txid.clone(), + l.lock_txid.clone(), + l.abort_txid.clone(), + l.sold_txid.clone(), + l.cancel_txid.clone(), + ] + .into_iter() + .flatten() + .all(|t| t == t.to_ascii_lowercase()) + }; + // Inserted. + let f = fx(ListingState::Listed); + let mut row = listing(&f); + row.id = "l2".into(); + row.name = "othername".into(); + row.lock_transfer_txid = Some(upper(&txid("a1"))); + row.lock_txid = Some(upper(&txid("a2"))); + row.abort_txid = Some(upper(&txid("a3"))); + row.sold_txid = Some(upper(&txid("a4"))); + row.cancel_txid = Some(upper(&txid("a5"))); + queries::insert_shakedex_listing(&f.conn, &row).unwrap(); + let got = queries::get_shakedex_listing(&f.conn, "l2") + .unwrap() + .unwrap(); + assert!(lc(&got), "{got:?}"); + assert!( + queries::shakedex_listing_holds_lock_coin(&f.conn, PROFILE, &upper(&txid("a2")), 0) + .unwrap() + ); + // A sale named in upper case: found by the stored outpoint, stored lower. + let lock = upper(&f.lock_txid); + assert_eq!( + queries::sell_shakedex_listing(&f.conn, &f.id, &upper(&txid("b1")), (&lock, 0)).unwrap(), + 1 + ); + assert_eq!(listing(&f).sold_txid.as_deref(), Some(txid("b1").as_str())); + assert_eq!( + queries::resell_sold_listing( + &f.conn, + &f.id, + ListingState::Sold, + &upper(&txid("b2")), + (&lock, 0) + ) + .unwrap(), + 1 + ); + assert_eq!(listing(&f).sold_txid.as_deref(), Some(txid("b2").as_str())); + // Adopted, and sold through a proven lock. + let f = fx(ListingState::ReadyToFinalize); + f.conn + .execute( + "UPDATE shakedex_listings SET lock_txid = NULL, lock_vout = NULL", + [], + ) + .unwrap(); + assert_eq!( + queries::adopt_lock_finalized_elsewhere(&f.conn, &f.id, &upper(&txid("c1")), 0).unwrap(), + 1 + ); + assert_eq!(listing(&f).lock_txid.as_deref(), Some(txid("c1").as_str())); + let f = fx(ListingState::Locking); + f.conn + .execute( + "UPDATE shakedex_listings SET lock_txid = NULL, lock_vout = NULL", + [], + ) + .unwrap(); + let proven = upper(&txid("c2")); + queries::sell_listing_through_proven_lock(&f.conn, &f.id, &upper(&txid("b3")), (&proven, 1)) + .unwrap(); + assert!(lc(&listing(&f)), "{:?}", listing(&f)); + // A Cancel transfer linked by its lock TRANSFER named in upper case. + let f = fx(ListingState::Locking); + let n = queries::link_shakedex_listing_abort( + &f.conn, + PROFILE, + NAME, + &upper(&f.transfer_txid), + 0, + "fin", + &upper(&txid("d1")), + ) + .unwrap(); + assert_eq!(n, 1); + assert_eq!(listing(&f).abort_txid.as_deref(), Some(txid("d1").as_str())); + // Finalize & sign's lock outpoint, and a file upgrading a Restored lock + // named in upper case. + let f = fx(ListingState::ReadyToFinalize); + let tx = f.conn.unchecked_transaction().unwrap(); + let fin = queries::FinalizingListing { + finalize_draft_id: "fin", + lock_txid: &upper(&txid("e2")), + lock_vout: 0, + steps_json: "[]", + listing_file_json: "{}", + expires_at: 1, + }; + assert_eq!( + queries::mark_listing_finalizing_in_tx(&tx, &f.id, &fin).unwrap(), + 1 + ); + tx.commit().unwrap(); + assert_eq!(listing(&f).lock_txid.as_deref(), Some(txid("e2").as_str())); + let f = fx(ListingState::Restored); + let n = queries::upgrade_restored_lock( + &f.conn, + &f.id, + &upper(&f.lock_txid), + f.lock_vout, + &listing(&f).lock_pubkey_hex, + &queries::UpgradedListing { + mode: ListingMode::BuyNow, + payment_address: &f.payment, + steps_json: "[]", + listing_file_json: "{}", + expires_at: None, + }, + ) + .unwrap(); + assert_eq!(n, 1); +} + +/// The guards of the sale, resell, unadopt and upgrade writes beside the +/// state: the listing's own lock outpoint, its lock TRANSFER, its key. +#[test] +fn sale_writes_move_only_a_listing_in_our_lock() { + // Sold is moved only to SalePending or Sold, and only for its own lock + // outpoint. + let f = fx(ListingState::Sold); + let b2 = txid("b2"); + assert!(queries::resell_sold_listing( + &f.conn, + &f.id, + ListingState::Listed, + &b2, + (&f.lock_txid, 0) + ) + .is_err()); + for (to, other) in [ + (ListingState::SalePending, (txid("c1"), 0)), + (ListingState::Sold, (txid("c1"), 0)), + (ListingState::Sold, (txid("f1"), 1)), + ] { + assert_eq!( + queries::resell_sold_listing(&f.conn, &f.id, to, &b2, (&other.0, other.1)).unwrap(), + 0, + "{to:?} {other:?}" + ); + } + assert_eq!( + (listing(&f).state, listing(&f).sold_txid), + (ListingState::Sold, None) + ); + // A sale of the outpoint the listing has is written; a purchase of + // another outpoint is another lock's and changes nothing. + let f = fx(ListingState::Listed); + let other = (txid("c1"), 3); + let o = (other.0.as_str(), other.1); + assert_eq!( + queries::sell_shakedex_listing(&f.conn, &f.id, &txid("b1"), o).unwrap(), + 0 + ); + assert_eq!( + queries::mark_listing_sale_pending(&f.conn, &f.id, &txid("b1"), o).unwrap(), + 0 + ); + let l = listing(&f); + assert_eq!((l.state, l.sold_txid), (ListingState::Listed, None)); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(txid("f1").as_str()), Some(0)) + ); + let vout = (f.lock_txid.as_str(), 1); + assert_eq!( + queries::sell_shakedex_listing(&f.conn, &f.id, &txid("b1"), vout).unwrap(), + 0 + ); + let same = (f.lock_txid.as_str(), 0); + assert_eq!( + queries::sell_shakedex_listing(&f.conn, &f.id, &txid("b1"), same).unwrap(), + 1 + ); + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(txid("b1").as_str())) + ); + // A listing without a stored outpoint is never sold or pending through + // these writes, whatever outpoint the purchase names: only + // `sell_listing_through_proven_lock` gives a listing an outpoint on sale. + for pending in [false, true] { + let f = fx(ListingState::Listed); + f.conn + .execute( + "UPDATE shakedex_listings SET lock_txid = NULL, lock_vout = NULL", + [], + ) + .unwrap(); + let write = |lock| { + if pending { + queries::mark_listing_sale_pending(&f.conn, &f.id, &txid("b1"), lock).unwrap() + } else { + queries::sell_shakedex_listing(&f.conn, &f.id, &txid("b1"), lock).unwrap() + } + }; + assert_eq!( + write(o), + 0, + "an outpoint only the purchase names (pending {pending})" + ); + let l = listing(&f); + assert_eq!( + (l.state, l.lock_txid, l.lock_vout), + (ListingState::Listed, None, None), + "pending {pending}" + ); + } + // A Restored lock taken back by a reorg is Locking without its outpoint; + // one restored by name (no lock TRANSFER known) is not moved. + let f = fx(ListingState::Restored); + queries::unadopt_restored_lock(&f.conn, &f.id).unwrap(); + let l = listing(&f); + assert_eq!((l.state, l.lock_txid), (ListingState::Locking, None)); + let f = fx(ListingState::Restored); + f.conn + .execute("UPDATE shakedex_listings SET lock_transfer_txid = NULL", []) + .unwrap(); + assert_eq!(queries::unadopt_restored_lock(&f.conn, &f.id).unwrap(), 0); + // A file upgrades only a Restored lock tracking that very outpoint. + let upgrade_key = |f: &Fx, vout: u32, key: &str| { + queries::upgrade_restored_lock( + &f.conn, + &f.id, + &f.lock_txid, + vout, + key, + &queries::UpgradedListing { + mode: ListingMode::BuyNow, + payment_address: &f.payment, + steps_json: "[]", + listing_file_json: "{}", + expires_at: None, + }, + ) + .unwrap() + }; + let upgrade = |f: &Fx, vout: u32| { + let key = listing(f).lock_pubkey_hex; + upgrade_key(f, vout, &key) + }; + assert_eq!(upgrade(&fx(ListingState::Listed), 0), 0, "not Restored"); + assert_eq!( + upgrade_key(&fx(ListingState::Restored), 0, "02ab"), + 0, + "another key" + ); + assert_eq!( + upgrade(&fx(ListingState::Restored), 1), + 0, + "another outpoint" + ); + let f = fx(ListingState::Restored); + assert_eq!(upgrade(&f, 0), 1); + assert_eq!(listing(&f).state, ListingState::Listed); +} + +/// The Sold write through a lock coin the job proved to be the listing's +/// (`shakedex_jobs::finalize_into_lock`): only from Locking or +/// ReadyToFinalize (pinned as a literal; the stored-outpoint sale keeps +/// Locking out), +/// only while the listing has no lock outpoint, and the proven coin becomes +/// it. +#[test] +fn proven_lock_sale_moves_only_a_listing_before_the_lock_without_an_outpoint() { + let mut set: Vec<&str> = ListingWrite::ProvenLockSale + .from() + .iter() + .map(|s| s.as_str()) + .collect(); + set.sort_unstable(); + assert_eq!(set, ["locking", "ready_to_finalize"]); + assert!(!ListingWrite::Sell.from().contains(&ListingState::Locking)); + let proven = (txid("f2"), 1); + for state in ListingState::ALL { + let f = fx(state); + // With its own outpoint: never. + let n = queries::sell_listing_through_proven_lock( + &f.conn, + &f.id, + &txid("b1"), + (&proven.0, proven.1), + ) + .unwrap(); + assert_eq!(n, 0, "{state:?}: with an outpoint"); + f.conn + .execute( + "UPDATE shakedex_listings SET lock_txid = NULL, lock_vout = NULL WHERE id = ?1", + [&f.id], + ) + .unwrap(); + let n = queries::sell_listing_through_proven_lock( + &f.conn, + &f.id, + &txid("b1"), + (&proven.0, proven.1), + ) + .unwrap(); + let moves = ListingWrite::ProvenLockSale.from().contains(&state); + assert_eq!(n == 1, moves, "{state:?}: without an outpoint"); + let l = listing(&f); + if moves { + assert_eq!(l.state, ListingState::Sold); + assert_eq!(l.sold_txid.as_deref(), Some(txid("b1").as_str())); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(proven.0.as_str()), Some(1)) + ); + } else { + assert_eq!((l.state, l.lock_txid), (state, None), "{state:?}"); + } + } +} + +#[test] +fn a_sold_listing_goes_back_only_while_no_other_listing_of_the_name_is_open() { + let f = fx(ListingState::Sold); + f.conn + .execute("UPDATE shakedex_listings SET sold_txid = ?1", [txid("b1")]) + .unwrap(); + // Any `to` but these three is a bug in the caller. + assert!(queries::unsell_shakedex_listing(&f.conn, &f.id, ListingState::Locking).is_err()); + let mut newer = listing(&f); + newer.id = "l2".into(); + newer.state = ListingState::Locking; + newer.lock_txid = None; + newer.lock_vout = None; + queries::insert_shakedex_listing(&f.conn, &newer).unwrap(); + assert_eq!( + queries::unsell_shakedex_listing(&f.conn, &f.id, ListingState::Listed).unwrap(), + 0 + ); + assert_eq!(listing(&f).state, ListingState::Sold); + f.conn + .execute("DELETE FROM shakedex_listings WHERE id = 'l2'", []) + .unwrap(); + for to in [ + ListingState::Listed, + ListingState::Finalizing, + ListingState::Restored, + ] { + set_state(&f, ListingState::Sold); + assert_eq!( + queries::unsell_shakedex_listing(&f.conn, &f.id, to).unwrap(), + 1, + "{to:?}" + ); + let l = listing(&f); + assert_eq!((l.state, l.sold_txid), (to, None), "{to:?}"); + } + set_state(&f, ListingState::SalePending); + assert_eq!( + queries::unsell_shakedex_listing(&f.conn, &f.id, ListingState::Listed).unwrap(), + 1 + ); + set_state(&f, ListingState::Listed); + assert_eq!( + queries::unsell_shakedex_listing(&f.conn, &f.id, ListingState::Listed).unwrap(), + 0 + ); +} + +#[test] +fn own_coins_at_the_payment_address_include_spent_ones() { + let f = fx(ListingState::Listed); + paid(&f, &txid("b1"), 2, TIP, true); + paid(&f, &txid("b2"), 1, -1, false); + f.conn + .execute( + "INSERT INTO tracked_utxos (txid, vout, wallet_profile_id, address, script_pubkey_hex, + value_doos, height, covenant_type, spend_class) + VALUES (?1, 0, ?2, ?3, '00', 1, 5, 0, 'liquid_hns')", + params![txid("b3"), PROFILE, f.cancel], + ) + .unwrap(); + let got = queries::own_coins_at(&f.conn, PROFILE, &f.payment).unwrap(); + assert_eq!(got, vec![(txid("b1"), Some(TIP)), (txid("b2"), Some(-1))]); + assert!(queries::own_coin_in_tx(&f.conn, PROFILE, &txid("b1"), &f.payment).unwrap()); + assert!(!queries::own_coin_in_tx(&f.conn, PROFILE, &txid("b3"), &f.payment).unwrap()); + assert!(queries::own_coins_at(&f.conn, "other", &f.payment) + .unwrap() + .is_empty()); +} + +#[test] +fn after_lock_job_rechecks_sold_listings_only_within_the_window() { + let f = fx(ListingState::Sold); + assert_eq!( + queries::list_shakedex_listings_after_lock(&f.conn, PROFILE, 7) + .unwrap() + .len(), + 1 + ); + f.conn + .execute( + "UPDATE shakedex_listings SET updated_at = datetime('now', '-8 days')", + [], + ) + .unwrap(); + assert!( + queries::list_shakedex_listings_after_lock(&f.conn, PROFILE, 7) + .unwrap() + .is_empty() + ); + set_state(&f, ListingState::Restored); + f.conn + .execute( + "UPDATE shakedex_listings SET updated_at = datetime('now', '-80 days')", + [], + ) + .unwrap(); + assert_eq!( + queries::list_shakedex_listings_after_lock(&f.conn, PROFILE, 7) + .unwrap() + .len(), + 1 + ); +} + +fn name_hash() -> String { + hex::encode(names::hash_name(NAME).unwrap()) +} + +fn height_item(h: u32) -> String { + hex::encode(h.to_le_bytes()) +} + +/// A coin as hsd's `GET /coin` sends it. +fn coin( + txid: &str, + vout: u32, + address: &str, + cov: u8, + items: Vec, + height: i64, +) -> NodeCoin { + serde_json::from_value(json!({ + "version": 0, "height": height, "value": 1_000_000, "address": address, + "covenant": { "type": cov, "action": "", "items": items }, + "coinbase": false, "hash": txid, "index": vout + })) + .unwrap() +} + +/// The lock coin: a FINALIZE of NAME at the lock address. +fn lock_coin(f: &Fx, height: i64) -> NodeCoin { + coin( + &f.lock_txid, + f.lock_vout, + &f.lock, + COV_FINALIZE, + vec![name_hash(), height_item(NAME_HEIGHT)], + height, + ) +} + +/// Output 0 of `txid`: a TRANSFER of NAME at our lock committing to `to`. +fn transfer_out_of_lock(f: &Fx, txid: &str, to: &str, height: i64) -> NodeCoin { + let (v, h) = address::decode(NET, to).unwrap(); + coin( + txid, + 0, + &f.lock, + COV_TRANSFER, + vec![ + name_hash(), + height_item(NAME_HEIGHT), + hex::encode([v]), + hex::encode(h), + ], + height, + ) +} + +/// `getnameinfo` with `owner` the name's owner outpoint. +fn info(owner: (&str, u32)) -> Value { + json!({ "info": { + "name": NAME, "state": "CLOSED", "height": NAME_HEIGHT, "renewal": 1_000, + "renewals": 0, "claimed": 0, "weak": false, "transfer": 0, "revoked": 0, + "owner": { "hash": owner.0, "index": owner.1 }, "value": 1_000_000 + }, "start": null }) +} + +/// The purchase `txid` as hsd's `GET /tx` sends it (`height` -1 in the +/// mempool): input 0 our lock coin, output 0 the TRANSFER at the lock +/// committing to the buyer, output 1 change, output 2 the price to `pay`. +fn purchase_rest(f: &Fx, txid: &str, height: i64, pay: &str) -> Value { + let (_, h) = address::decode(NET, &f.buyer).unwrap(); + json!({ + "hash": txid, "height": height, "hex": "00", + "inputs": [ { "prevout": { "hash": f.lock_txid, "index": f.lock_vout } }, + { "prevout": { "hash": "aa".repeat(32), "index": 1 } } ], + "outputs": [ + { "value": 1_000_000, "address": f.lock, "covenant": { "type": COV_TRANSFER, + "action": "TRANSFER", + "items": [name_hash(), height_item(NAME_HEIGHT), "00", hex::encode(h)] } }, + { "value": 1, "address": f.buyer, + "covenant": { "type": 0, "action": "NONE", "items": [] } }, + { "value": PRICE, "address": pay, + "covenant": { "type": 0, "action": "NONE", "items": [] } } + ] + }) +} + +/// A node answering `info`, `GET /coin` from `coins` (hsd's empty 404 for +/// any other outpoint), and `GET /tx` with `tx` (`null`: hsd's not-found). +fn node(info: Value, coins: Vec, tx: Value) -> MockNodeRpc { + MockNodeRpc::new() + .with_name_info(info) + .with_tx_by_hash(tx) + .with_get_coin(move |txid, vout| { + Ok(coins + .iter() + .find(|c| c.txid == txid && c.vout == vout) + .cloned()) + }) +} + +/// Run the listing step's body on the fixture's database; it sends nothing. +async fn run(f: &Fx, rpc: &MockNodeRpc) { + crate::shakedex_jobs::refresh_listings_with_client(&f.conn, rpc, PROFILE) + .await + .expect("listing step runs"); + assert_eq!( + rpc.count_matching(|c| matches!(c, RpcCall::SendRawTransaction(_))), + 0, + "the listing step sends nothing (it runs in the daemon too)" + ); +} + +/// `rest` with input witnesses as hsd sends them (hex items): input 0, the +/// lock coin, signed with sighash type `lock_sighash` (`[signature, +/// lock script]`), input 1 a P2WPKH spend. +fn witnessed(rest: &Value, lock_sighash: u8) -> Value { + let mut v = rest.clone(); + v["inputs"][0]["witness"] = json!([ + format!("{}{:02x}", "aa".repeat(64), lock_sighash), + "76".repeat(40) + ]); + v["inputs"][1]["witness"] = json!([format!("{}01", "bb".repeat(64)), "02".repeat(33)]); + v +} + +/// R22 for a lock restored by name rests on positive evidence in the +/// spending transaction: a cancel of the same seed (sighash `0x83`) to an +/// address this device has not derived (past its restore window, spec §5) +/// commits to an address "not ours" here, yet is no sale; nor is a +/// transaction whose lock input carries no witness. Only a price step's +/// `0x84` is. Read with `GET /tx` and from the block (no index). +#[tokio::test] +async fn a_restored_lock_is_sold_only_by_a_price_step_signature() { + let buy = txid("b1"); + let stranger = address::encode_p2wpkh(NET, &[8; 20]).unwrap(); + for indexed in [true, false] { + for (case, sighash, want) in [ + ("0x84 price step", Some(0x84u8), ListingState::Sold), + ( + "0x83 cancel past our window", + Some(0x83), + ListingState::Restored, + ), + ("no witness", None, ListingState::Restored), + ] { + let f = fx(ListingState::Restored); + restored_by_name(&f); + let plain = purchase_rest(&f, &buy, TIP, &stranger); + let rest = match sighash { + Some(h) => witnessed(&plain, h), + None => plain, + }; + let tx = if indexed { rest.clone() } else { Value::Null }; + let rpc = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + tx, + ) + .with_block_hash("bb".repeat(32)) + .with_block(block_with(&rest, TIP)); + run(&f, &rpc).await; + assert_eq!(listing(&f).state, want, "{case}, indexed {indexed}"); + } + } +} + +/// A lock restored by name (R32): no payment address, no file, no lock +/// TRANSFER of this device. +fn restored_by_name(f: &Fx) { + f.conn + .execute( + "UPDATE shakedex_listings SET lock_transfer_txid = NULL, payment_address = NULL, + lock_finalize_draft_id = NULL, listing_file_json = NULL, steps_json = '[]'", + [], + ) + .unwrap(); +} + +/// R22 for a lock restored by name, which knows no payment address: a +/// TRANSFER out of our lock coin needs the lock key's signature, so one +/// committing to an address not ours can only be a price step (`0x84`), +/// which commits to its payment output. Mined, and linked from the stored +/// lock outpoint (input k the lock coin, output k the TRANSFER of the name +/// at our lock), it is Sold with that txid, found through the owner without +/// the transaction index (the block at the owner coin's height). Committing +/// to an address of ours (our cancel: T5), in the mempool, linked from +/// another coin, or not readable: no verdict. +#[tokio::test] +async fn restored_lock_by_name_is_sold_by_a_mined_transfer_out_of_its_lock() { + let buy = txid("b1"); + let stranger = address::encode_p2wpkh(NET, &[8; 20]).unwrap(); + // Mined, read with `GET /tx`, and again from the block (no index). + for indexed in [true, false] { + let f = fx(ListingState::Restored); + restored_by_name(&f); + let rest = witnessed(&purchase_rest(&f, &buy, TIP, &stranger), 0x84); + let tx = if indexed { rest.clone() } else { Value::Null }; + let rpc = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + tx, + ) + .with_block_hash("bb".repeat(32)) + .with_block(block_with(&rest, TIP)); + run(&f, &rpc).await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref(), l.lock_txid.as_deref()), + ( + ListingState::Sold, + Some(buy.as_str()), + Some(f.lock_txid.as_str()) + ), + "indexed {indexed}" + ); + } + // Committing to an address of ours: a cancel, T5's. + let f = fx(ListingState::Restored); + restored_by_name(&f); + // (Witnessed as a price step, so only the commitment says no.) + let mut rest = witnessed(&purchase_rest(&f, &buy, TIP, &stranger), 0x84); + let (_, ours) = address::decode(NET, &f.cancel).unwrap(); + rest["outputs"][0]["covenant"]["items"][3] = hex::encode(ours).into(); + run( + &f, + &node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.cancel, TIP)], + rest, + ), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Restored, "our cancel"); + // In the mempool: the owner is still the lock coin. + let f = fx(ListingState::Restored); + restored_by_name(&f); + run( + &f, + &node( + info((&f.lock_txid, f.lock_vout)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, -1)], + purchase_rest(&f, &buy, -1, &stranger), + ), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Restored, "mempool"); + // Linked from another coin: input 0 is not our lock coin. + let f = fx(ListingState::Restored); + restored_by_name(&f); + let mut rest = witnessed(&purchase_rest(&f, &buy, TIP, &stranger), 0x84); + rest["inputs"][0]["prevout"]["hash"] = txid("c1").into(); + run( + &f, + &node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + rest, + ), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Restored, "another coin"); + // The TRANSFER's transaction not found anywhere: no verdict. + let f = fx(ListingState::Restored); + restored_by_name(&f); + run( + &f, + &node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + Value::Null, + ) + .with_block_hash("bb".repeat(32)) + .with_block(json!({ "height": TIP, "tx": [] })), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Restored, "not found"); +} + +/// R22: the lock coin bought. hsd moves the name's owner to the purchase's +/// TRANSFER (output 0) out of our lock, committing to an address not ours, +/// and the wallet has a coin of that transaction at the payment address: +/// Sold with the purchase's txid, the lock outpoint kept — from Listed, from +/// a purchase seen pending, from a Restored lock that has a payment address, +/// and from Finalizing (bought before a sync saw the FINALIZE mined). +#[tokio::test] +async fn sold_on_purchase() { + for from in [ + ListingState::Listed, + ListingState::SalePending, + ListingState::Restored, + ListingState::Finalizing, + ] { + let f = fx(from); + let buy = txid("b1"); + paid(&f, &buy, 2, TIP, false); + // No transaction index: the owner's transaction is read from the + // block at the owner coin's height. + let chain = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + Value::Null, + ) + .with_block_hash("ab".repeat(32)) + .with_block(block_with(&purchase_rest(&f, &buy, TIP, &f.payment), TIP)); + run(&f, &chain).await; + let l = listing(&f); + assert_eq!(l.state, ListingState::Sold, "{from:?}"); + assert_eq!(l.sold_txid.as_deref(), Some(buy.as_str()), "{from:?}"); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(f.lock_txid.as_str()), Some(0)), + "{from:?}" + ); + } +} + +/// R22: coins arriving at the payment address on their own mean nothing. +/// (a) a coin there while the lock coin is unspent; (b) the lock coin spent +/// into a TRANSFER at our lock committing to our own cancel address — even +/// with a coin of ours in that very transaction at the payment address, so +/// the owner path reads the commitment and says "ours"; (c) the lock coin +/// bought by a transaction that does not pay us, and a payment from another +/// one that buys nothing, read from hsd (so the "no" comes from the rule, +/// not from a read the mock does not answer). +#[tokio::test] +async fn payment_alone_is_not_sold() { + let other = txid("d1"); + // (a) + let f = fx(ListingState::Listed); + paid(&f, &other, 0, TIP, false); + let mut gift = purchase_rest(&f, &other, TIP, &f.payment); + gift["outputs"][0] = json!({ "value": 1, "address": f.buyer, + "covenant": { "type": 0, "action": "NONE", "items": [] } }); + let chain = node( + info((&f.lock_txid, 0)), + vec![lock_coin(&f, TIP - 20)], + gift.clone(), + ); + run(&f, &chain).await; + assert_eq!(listing(&f).state, ListingState::Listed, "lock coin unspent"); + // (b) + let f = fx(ListingState::Listed); + let cancel = txid("c1"); + paid(&f, &cancel, 1, TIP, false); + let chain = node( + info((&cancel, 0)), + vec![transfer_out_of_lock(&f, &cancel, &f.cancel, TIP)], + gift.clone(), + ); + run(&f, &chain).await; + assert_eq!(listing(&f).state, ListingState::Listed, "our cancel"); + assert!( + chain.count_matching(|c| matches!(c, RpcCall::GetCoin(t, 0) if *t == cancel)) > 0, + "the owner coin was read" + ); + // (c) + let f = fx(ListingState::Listed); + let buy = txid("b1"); + paid(&f, &other, 0, TIP, false); + let chain = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + gift, + ); + run(&f, &chain).await; + assert_eq!( + listing(&f).state, + ListingState::Listed, + "bought, but not paying us" + ); + assert!( + chain.count_matching(|c| matches!(c, RpcCall::TxByHash(t) if *t == other)) > 0, + "the paying transaction was read" + ); + // (d) the owner a TRANSFER committing to the buyer in a transaction that + // pays us, but not out of our lock: at another address, or of another + // name at our lock. + let (v, h) = address::decode(NET, &f.buyer).unwrap(); + let commit = |name: String| { + vec![ + name, + height_item(NAME_HEIGHT), + hex::encode([v]), + hex::encode(h.clone()), + ] + }; + let other_name = hex::encode(names::hash_name("othername").unwrap()); + for (case, at, items) in [ + ("not at our lock", f.buyer.clone(), commit(name_hash())), + ("another name", f.lock.clone(), commit(other_name)), + ] { + let f = fx(ListingState::Listed); + let x = txid("e7"); + paid(&f, &x, 2, TIP, false); + let chain = node( + info((&x, 0)), + vec![coin(&x, 0, &at, COV_TRANSFER, items, TIP)], + gift_of(&f, &x), + ); + run(&f, &chain).await; + assert_eq!(listing(&f).state, ListingState::Listed, "{case}"); + } + // (e) the owner a mined TRANSFER at our lock committing to the buyer, in + // a transaction that pays our payment address, but spending another lock + // coin of the name (same seed, same lock address: a re-listing elsewhere). + // The owner's transaction is read from its block and judged against our + // lock outpoint: not this listing's sale. + let f = fx(ListingState::Listed); + let x = txid("e8"); + paid(&f, &x, 2, TIP, false); + let mut relisted = purchase_rest(&f, &x, TIP, &f.payment); + relisted["inputs"][0]["prevout"] = json!({ "hash": txid("c2"), "index": 0 }); + let chain = node( + info((&x, 0)), + vec![transfer_out_of_lock(&f, &x, &f.buyer, TIP)], + Value::Null, + ) + .with_block_hash("ab".repeat(32)) + .with_block(block_with(&relisted, TIP)); + run(&f, &chain).await; + assert_eq!( + listing(&f).state, + ListingState::Listed, + "another lock coin bought" + ); + assert!( + chain.count_matching(|c| matches!(c, RpcCall::BlockHash(h) if *h == TIP)) > 0, + "the owner's transaction was read from its block" + ); +} + +/// hsd's `getblock true true` holding the one transaction `rest` +/// (hsd's `GET /tx` shape), at `height`. +fn block_with(rest: &Value, height: i64) -> Value { + let vin: Vec<_> = rest["inputs"] + .as_array() + .unwrap() + .iter() + .map(|i| { + let mut v = json!({ "txid": i["prevout"]["hash"], "vout": i["prevout"]["index"] }); + if let Some(w) = i.get("witness") { + v["txinwitness"] = w.clone(); + } + v + }) + .collect(); + let vout: Vec<_> = rest["outputs"] + .as_array() + .unwrap() + .iter() + .enumerate() + .map(|(n, o)| { + json!({ "n": n, "value": 0.0, + "address": { "version": 0, "hash": "00", "string": o["address"] }, + "covenant": o["covenant"] }) + }) + .collect(); + json!({ "height": height, "tx": [ { "txid": rest["hash"], "vin": vin, "vout": vout } ] }) +} + +/// A transaction that pays the payment address and buys nothing. +fn gift_of(f: &Fx, txid: &str) -> Value { + let mut gift = purchase_rest(f, txid, TIP, &f.payment); + gift["outputs"][0] = json!({ "value": 1, "address": f.buyer, + "covenant": { "type": 0, "action": "NONE", "items": [] } }); + gift +} + +/// R22: the purchase only in the mempool — hsd answers 404 for the lock coin +/// a mempool transaction spends, and the name's owner is still the lock +/// coin (it moves when a block is connected); our coin of the purchase is +/// at -1, and `GET /tx` finds it in the mempool: SalePending. A purchase +/// that `GET /tx` reports mined while the owner is still the lock coin is +/// two hsd facts that disagree: no verdict. Mined, Sold. +#[tokio::test] +async fn sale_pending_while_the_purchase_is_in_the_mempool() { + let f = fx(ListingState::Listed); + let buy = txid("b1"); + paid(&f, &buy, 2, -1, false); + let lock_owner = info((&f.lock_txid, 0)); + let disagree = node( + lock_owner.clone(), + vec![], + purchase_rest(&f, &buy, TIP, &f.payment), + ); + run(&f, &disagree).await; + assert_eq!( + listing(&f).state, + ListingState::Listed, + "mined, owner not moved" + ); + + // In the mempool while the owner has moved elsewhere: disagree too. + let moved = node( + info((&txid("0e"), 0)), + vec![], + purchase_rest(&f, &buy, -1, &f.payment), + ); + run(&f, &moved).await; + assert_eq!( + listing(&f).state, + ListingState::Listed, + "pending, owner moved" + ); + // The owner the purchase's TRANSFER, but hsd shows that coin in the + // mempool: not a mined purchase. + let unmined_owner = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, -1)], + Value::Null, + ); + run(&f, &unmined_owner).await; + assert_eq!( + listing(&f).state, + ListingState::Listed, + "owner coin unmined" + ); + + let pending = node(lock_owner, vec![], purchase_rest(&f, &buy, -1, &f.payment)); + run(&f, &pending).await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::SalePending, Some(buy.as_str())) + ); + + paid(&f, &buy, 2, TIP + 1, false); + let mined = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP + 1)], + purchase_rest(&f, &buy, TIP + 1, &f.payment), + ); + run(&f, &mined).await; + assert_eq!(listing(&f).state, ListingState::Sold); +} + +/// A node reply missing a field the verdict reads leaves the state as it +/// is, with every other fact of a purchase in place: a name reply without +/// `info`, without the owner, or without its index; and, for a listing whose +/// lock coin is unspent, a name reply without the name's height. +#[tokio::test] +async fn name_reply_without_owner_leaves_the_listing_as_it_is() { + let buy = txid("b1"); + for state in [ + ListingState::Listed, + ListingState::SalePending, + ListingState::Restored, + ] { + let f = fx(state); + paid(&f, &buy, 2, TIP, false); + let full = info((&buy, 0)); + let mut no_owner = full.clone(); + no_owner["info"].as_object_mut().unwrap().remove("owner"); + let mut no_index = full.clone(); + no_index["info"]["owner"] + .as_object_mut() + .unwrap() + .remove("index"); + for (case, reply) in [ + ("no info", json!({ "start": null })), + ("no owner", no_owner), + ("no index", no_index), + ] { + let chain = node( + reply, + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + purchase_rest(&f, &buy, TIP, &f.payment), + ); + run(&f, &chain).await; + assert_eq!(listing(&f).state, state, "{state:?}: {case}"); + } + } + let f = fx(ListingState::Listed); + let mut no_height = info((&f.lock_txid, 0)); + no_height["info"].as_object_mut().unwrap().remove("height"); + run( + &f, + &node(no_height, vec![lock_coin(&f, TIP - 20)], Value::Null), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Listed, "no name height"); +} + +/// R22 and SECURITY.md (the daemon never signs or broadcasts): the listing +/// step runs in `run_sync_steps`, in `namehold-syncd`'s sync and in the +/// app's, finds the sale, and makes no send. The market half is a forward +/// guard (T6 adds market jobs): the profile's LearnHNS base URL points at a +/// server that must see no request from this step. +#[tokio::test] +async fn daemon_refresh_makes_no_send_and_no_market_call() { + use crate::commands::sync::{run_sync_steps, SyncCaller, SyncStatus}; + + for caller in [SyncCaller::Daemon, SyncCaller::App] { + let f = fx(ListingState::Listed); + let buy = txid("b1"); + paid(&f, &buy, 2, TIP, false); + let mut node = mockito::Server::new_async().await; + let mut market = mockito::Server::new_async().await; + let path = std::env::temp_dir().join(format!( + "namehold_sold_sync_{}_{caller:?}.db", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let db_path = path.to_str().unwrap().to_string(); + queries::set_setting(&f.conn, "node_rpc_url", &node.url()).unwrap(); + queries::set_setting(&f.conn, "learnhns_base_url", &market.url()).unwrap(); + f.conn.execute("VACUUM INTO ?1", params![db_path]).unwrap(); + let rpc = |s: &mut mockito::ServerGuard, method: &str, result: Value| { + s.mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(json!({ "method": method }))) + .with_header("content-type", "application/json") + .with_body(crate::tests::shakedex_cmd_tests::rpc_ok(result)) + }; + let _chain = rpc( + &mut node, + "getblockchaininfo", + json!({ + "chain": "regtest", "blocks": TIP, "headers": TIP, + "verificationprogress": 1.0, "mediantime": 1_700_000_000 + }), + ) + .create_async() + .await; + let _name = rpc(&mut node, "getnameinfo", info((&buy, 0))) + .create_async() + .await; + let send = node + .mock("POST", "/") + .match_body(mockito::Matcher::Regex("sendrawtransaction".into())) + .expect(0) + .create_async() + .await; + let _lock = node + .mock("GET", format!("/coin/{}/0", f.lock_txid).as_str()) + .with_status(404) + .create_async() + .await; + // The lock TRANSFER the FINALIZE into the lock spent: hsd's 404. + let _lock_transfer = node + .mock("GET", format!("/coin/{}/0", f.transfer_txid).as_str()) + .with_status(404) + .create_async() + .await; + let transfer = transfer_out_of_lock(&f, &buy, &f.buyer, TIP); + let _transfer = node + .mock("GET", format!("/coin/{buy}/0").as_str()) + .with_header("content-type", "application/json") + .with_body( + json!({ + "version": 0, "height": TIP, "value": 1_000_000, + "address": transfer.address, + "covenant": { "type": COV_TRANSFER, "action": "TRANSFER", + "items": transfer.covenant.as_ref().unwrap().items }, + "coinbase": false, "hash": buy, "index": 0 + }) + .to_string(), + ) + .create_async() + .await; + // No transaction index: hsd's empty 404 for the purchase, and its + // block at the owner coin's height. + let _no_tx = node + .mock("GET", format!("/tx/{buy}").as_str()) + .with_status(404) + .create_async() + .await; + let block_hash = "ab".repeat(32); + let _hash = rpc(&mut node, "getblockhash", json!(block_hash)) + .create_async() + .await; + let _block = rpc( + &mut node, + "getblock", + block_with(&purchase_rest(&f, &buy, TIP, &f.payment), TIP), + ) + .create_async() + .await; + let no_market = market + .mock("GET", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + let no_market_post = market + .mock("POST", mockito::Matcher::Any) + .expect(0) + .create_async() + .await; + + match caller { + SyncCaller::Daemon => crate::daemon::sync_profile(&db_path, PROFILE).await, + SyncCaller::App => { + let status = std::sync::Arc::new(tokio::sync::Mutex::new(SyncStatus::default())); + run_sync_steps(&status, &db_path, PROFILE, SyncCaller::App).await; + } + } + + send.assert_async().await; + no_market.assert_async().await; + no_market_post.assert_async().await; + let conn = Connection::open(&path).unwrap(); + let l = queries::get_shakedex_listing(&conn, &f.id) + .unwrap() + .unwrap(); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(buy.as_str())), + "{caller:?}" + ); + drop(conn); + let _ = std::fs::remove_file(&path); + } +} + +/// R22 once the buyer has finalized before our sync: the name's owner is the +/// buyer's FINALIZE elsewhere and the purchase's TRANSFER is spent, so the +/// purchase is read from our coin of it at the payment address: through +/// `GET /tx` (a node with the index), or on hsd's not-found from the block at +/// the height that coin was seen mined (no index). The lock coin is read +/// from the purchase. Without a mined height, or a block that does not hold +/// it, no verdict: a coin row is a lead, not proof. +#[tokio::test] +async fn sold_after_the_buyer_finalized_is_found_from_the_purchase() { + let buy = txid("b1"); + let fin = txid("a9"); + let block_of = + |f: &Fx, height: i64| block_with(&purchase_rest(f, &buy, height, &f.payment), height); + let finalized = info((&fin, 0)); + let buyer_coin = |f: &Fx| coin(&fin, 0, &f.buyer, COV_FINALIZE, vec![name_hash()], TIP + 12); + + // With the index. + let f = fx(ListingState::Listed); + paid(&f, &buy, 2, TIP, true); + run( + &f, + &node( + finalized.clone(), + vec![buyer_coin(&f)], + purchase_rest(&f, &buy, TIP, &f.payment), + ), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Sold, "GET /tx"); + + // Without it: the block. + let f = fx(ListingState::Listed); + paid(&f, &buy, 2, TIP, true); + let chain = node(finalized.clone(), vec![buyer_coin(&f)], Value::Null) + .with_block_hash("ab".repeat(32)) + .with_block(block_of(&f, TIP)); + run(&f, &chain).await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(buy.as_str())), + "getblock" + ); + assert!(chain.count_matching(|c| matches!(c, RpcCall::BlockHash(h) if *h == TIP)) > 0); + + // Our coin seen only in the mempool: nothing to look up without the index. + let f = fx(ListingState::Listed); + paid(&f, &buy, 2, -1, true); + run( + &f, + &node(finalized.clone(), vec![buyer_coin(&f)], Value::Null), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Listed, "no mined height"); + + // The block at that height no longer holds it (a reorg moved it). + let f = fx(ListingState::Listed); + paid(&f, &buy, 2, TIP, true); + let chain = node(finalized.clone(), vec![buyer_coin(&f)], Value::Null) + .with_block_hash("ab".repeat(32)) + .with_block(json!({ "height": TIP, "tx": [] })); + run(&f, &chain).await; + assert_eq!(listing(&f).state, ListingState::Listed, "not in that block"); +} + +/// A transaction that cannot be read does not hold the listing for ever: +/// the owner's transaction and an earlier lead whose block entry is not +/// hsd's whole answer are skipped (skipping only withholds a verdict), and +/// the purchase found from the next lead decides. +#[tokio::test] +async fn an_unreadable_transaction_is_skipped() { + let f = fx(ListingState::Listed); + let bad = txid("a0"); + let buy = txid("b1"); + paid(&f, &bad, 2, TIP, false); + paid(&f, &buy, 2, TIP, false); + let mut block = block_with(&purchase_rest(&f, &buy, TIP, &f.payment), TIP); + let good = block["tx"][0].clone(); + block["tx"] = json!([{ "txid": bad, "vin": [] }, good]); + let chain = node( + info((&bad, 0)), + vec![transfer_out_of_lock(&f, &bad, &f.buyer, TIP)], + Value::Null, + ) + .with_block_hash("ab".repeat(32)) + .with_block(block); + run(&f, &chain).await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(buy.as_str())) + ); +} + +/// Review Focus 3: a reorg takes the FINALIZE into the lock away after the +/// steps are signed. Back in the mempool (the lock coin at -1) the listing is +/// Finalizing; in no block and no mempool (hsd's 404 for the lock coin, and +/// the lock TRANSFER it spent a coin again) Finalizing too; mined again, +/// Listed. A coin at the payment address from an unrelated transaction never +/// makes it SalePending or Sold meanwhile; a Sold listing whose FINALIZE is +/// taken away goes back to Finalizing as well. +#[tokio::test] +async fn listing_follows_a_reorg_of_its_lock_finalize() { + let f = fx(ListingState::Listed); + let other = txid("d1"); + paid(&f, &other, 0, TIP, false); + let gift = json!({ "hash": other, "height": TIP, "hex": "00", + "inputs": [ { "prevout": { "hash": "aa".repeat(32), "index": 0 } } ], + "outputs": [ { "value": PRICE, "address": f.payment, + "covenant": { "type": 0, "action": "NONE", "items": [] } } ] }); + let transfer_back = coin( + &f.transfer_txid, + 0, + &f.payment, + COV_TRANSFER, + vec![], + TIP - 40, + ); + let lock_owner = info((&f.lock_txid, 0)); + let transfer_owner = info((&f.transfer_txid, 0)); + for (case, reply, coins, want) in [ + ( + "in the mempool", + lock_owner.clone(), + vec![lock_coin(&f, -1)], + ListingState::Finalizing, + ), + ( + "mined", + lock_owner.clone(), + vec![lock_coin(&f, TIP)], + ListingState::Listed, + ), + ( + "nowhere", + transfer_owner.clone(), + vec![transfer_back.clone()], + ListingState::Finalizing, + ), + ( + "still nowhere", + transfer_owner, + vec![transfer_back.clone()], + ListingState::Finalizing, + ), + ( + "mined again", + lock_owner, + vec![lock_coin(&f, TIP + 1)], + ListingState::Listed, + ), + ] { + run(&f, &node(reply, coins, gift.clone())).await; + let l = listing(&f); + assert_eq!(l.state, want, "{case}"); + assert_eq!(l.sold_txid, None, "{case}"); + } + // Sold, and the FINALIZE into the lock reorged out with the purchase. + set_state(&f, ListingState::Sold); + run( + &f, + &node(info((&f.transfer_txid, 0)), vec![transfer_back], gift), + ) + .await; + assert_eq!( + listing(&f).state, + ListingState::Finalizing, + "sold, FINALIZE nowhere" + ); +} + +/// R22, a reorg of the purchase. Its block taken away and the purchase in no +/// mempool: hsd shows the lock coin as a coin again (it answers 404 for a +/// coin any mempool transaction spends) → Listed, the purchase forgotten. +/// Back in the mempool → SalePending; mined again → Sold. A Sold listing +/// whose name has another open listing by now stays Sold. +#[tokio::test] +async fn sold_reverts_on_a_reorg_of_the_purchase() { + let f = fx(ListingState::Listed); + let buy = txid("b1"); + paid(&f, &buy, 2, TIP, false); + let sold = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + purchase_rest(&f, &buy, TIP, &f.payment), + ); + run(&f, &sold).await; + assert_eq!(listing(&f).state, ListingState::Sold); + + run( + &f, + &node( + info((&f.lock_txid, 0)), + vec![lock_coin(&f, TIP - 20)], + Value::Null, + ), + ) + .await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid), + (ListingState::Listed, None), + "purchase nowhere" + ); + + paid(&f, &buy, 2, -1, false); + run( + &f, + &node( + info((&f.lock_txid, 0)), + vec![], + purchase_rest(&f, &buy, -1, &f.payment), + ), + ) + .await; + assert_eq!( + listing(&f).state, + ListingState::SalePending, + "back in the mempool" + ); + + paid(&f, &buy, 2, TIP + 2, false); + let again = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP + 2)], + purchase_rest(&f, &buy, TIP + 2, &f.payment), + ); + run(&f, &again).await; + assert_eq!(listing(&f).state, ListingState::Sold, "mined again"); + + // A newer listing of the name is open: the old sale stays Sold. + let mut newer = listing(&f); + newer.id = "l2".into(); + newer.state = ListingState::Locking; + newer.lock_txid = None; + newer.lock_vout = None; + queries::insert_shakedex_listing(&f.conn, &newer).unwrap(); + run( + &f, + &node( + info((&f.lock_txid, 0)), + vec![lock_coin(&f, TIP - 20)], + Value::Null, + ), + ) + .await; + assert_eq!( + listing(&f).state, + ListingState::Sold, + "another listing is open" + ); +} + +/// R22, R31: a name that expired while locked ends the listing as Expired, +/// never Sold, whatever arrived at the payment address: hsd reports no live +/// state (`info: null`), or the name was opened again (hsd's name height is +/// not the lock coin's). From Listed and from a Restored lock, with the lock +/// coin unspent; and from Listed with the lock coin spent. +#[tokio::test] +async fn expired_lock_is_expired_not_sold() { + let other = txid("d1"); + let mut reopened = info((&"00".repeat(32), u32::MAX)); + reopened["info"]["height"] = 7_000.into(); + for state in [ListingState::Listed, ListingState::Restored] { + for (case, reply) in [ + ("info null", json!({ "info": null, "start": null })), + ("reopened", reopened.clone()), + ] { + let f = fx(state); + paid(&f, &other, 0, TIP, false); + run(&f, &node(reply, vec![lock_coin(&f, TIP - 20)], Value::Null)).await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid), + (ListingState::Expired, None), + "{state:?} {case}" + ); + assert_eq!( + l.lock_txid.as_deref(), + Some(f.lock_txid.as_str()), + "{state:?} {case}" + ); + } + } + let f = fx(ListingState::Listed); + let buy = txid("b1"); + paid(&f, &buy, 2, TIP, false); + run( + &f, + &node( + json!({ "info": null, "start": null }), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + purchase_rest(&f, &buy, TIP, &f.payment), + ), + ) + .await; + assert_eq!( + listing(&f).state, + ListingState::Expired, + "lock coin spent, name expired" + ); + // A purchase pending when the name expired under it: SalePending is + // Expired too, keeping its lock outpoint (R22, `ListingWrite::ExpireLocked`). + let f = fx(ListingState::SalePending); + f.conn + .execute("UPDATE shakedex_listings SET sold_txid = ?1", [&buy]) + .unwrap(); + paid(&f, &buy, 2, -1, false); + run( + &f, + &node( + json!({ "info": null, "start": null }), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, -1)], + purchase_rest(&f, &buy, -1, &f.payment), + ), + ) + .await; + let l = listing(&f); + assert_eq!( + (l.state, l.lock_txid.as_deref(), l.lock_vout), + (ListingState::Expired, Some(f.lock_txid.as_str()), Some(0)), + "sale pending, name expired" + ); + // A live name whose height is the lock coin's: Listed as before. + let f = fx(ListingState::Listed); + run( + &f, + &node( + info((&f.lock_txid, 0)), + vec![lock_coin(&f, TIP - 20)], + Value::Null, + ), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Listed); +} + +/// The Restored lock's follower (T3 carry): a reorg that takes away the +/// FINALIZE it was adopted from (the lock TRANSFER a coin again) makes it +/// Locking without the outpoint, for the before-lock job (a lock restored by +/// name, bought: `restored_lock_by_name_is_sold_by_a_mined_transfer_out_of_its_lock`). +#[tokio::test] +async fn restored_lock_follows_its_coin() { + let f = fx(ListingState::Restored); + let back = coin( + &f.transfer_txid, + 0, + &f.payment, + COV_TRANSFER, + vec![], + TIP - 40, + ); + run( + &f, + &node(info((&f.transfer_txid, 0)), vec![back], Value::Null), + ) + .await; + let l = listing(&f); + assert_eq!((l.state, l.lock_txid), (ListingState::Locking, None)); + + // A lock restored by name (no payment address) and bought: + // `restored_lock_by_name_is_sold_by_a_mined_transfer_out_of_its_lock`. + let buy = txid("b1"); + + // A Restored lock adopted from another device's FINALIZE keeps this + // device's payment address but has no listing file: Sold from a purchase + // paying it, and back to Restored (not Listed) when a reorg makes the + // lock coin a coin again. + let f = fx(ListingState::Restored); + f.conn + .execute( + "UPDATE shakedex_listings SET lock_finalize_draft_id = NULL, listing_file_json = NULL", + [], + ) + .unwrap(); + paid(&f, &buy, 2, TIP, false); + run( + &f, + &node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + purchase_rest(&f, &buy, TIP, &f.payment), + ), + ) + .await; + assert_eq!(listing(&f).state, ListingState::Sold, "adopted lock bought"); + run( + &f, + &node( + info((&f.lock_txid, 0)), + vec![lock_coin(&f, TIP - 20)], + Value::Null, + ), + ) + .await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid), + (ListingState::Restored, None), + "adopted lock, purchase nowhere" + ); + + // Sold from that adopted lock, and a reorg takes both the FINALIZE it + // was adopted from and the purchase away: the lock TRANSFER is a coin + // again → Restored, the purchase forgotten (the next sync takes it to + // Locking). + let f = fx(ListingState::Restored); + f.conn + .execute( + "UPDATE shakedex_listings SET state = 'sold', sold_txid = ?1, + lock_finalize_draft_id = NULL, listing_file_json = NULL", + [&buy], + ) + .unwrap(); + let back = coin( + &f.transfer_txid, + 0, + &f.payment, + COV_TRANSFER, + vec![], + TIP - 40, + ); + run( + &f, + &node(info((&f.transfer_txid, 0)), vec![back], Value::Null), + ) + .await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid), + (ListingState::Restored, None), + "adopted lock, FINALIZE and purchase nowhere" + ); +} + +/// R22, a real reorg of the purchase's block: hsd puts the purchase back in +/// its mempool (`mempool._removeBlock`), so the lock coin is hsd's 404, the +/// name's owner is the lock coin again and the purchase is at -1: a Sold +/// listing is SalePending again — unless another listing of the name is open +/// by now. A different purchase of the same lock coin mined instead replaces +/// the sale's txid. Its FINALIZE taken back to the mempool with the purchase +/// gone, a Sold listing is Finalizing at once (its file is not exported over +/// an unmined FINALIZE). +#[tokio::test] +async fn sold_follows_its_purchase_back_to_the_mempool_and_a_competing_purchase() { + let buy = txid("b1"); + let sold = |f: &Fx| { + f.conn + .execute( + "UPDATE shakedex_listings SET state = 'sold', sold_txid = ?1", + [&buy], + ) + .unwrap(); + }; + let f = fx(ListingState::Listed); + sold(&f); + paid(&f, &buy, 2, -1, false); + let back = node( + info((&f.lock_txid, 0)), + vec![], + purchase_rest(&f, &buy, -1, &f.payment), + ); + run(&f, &back).await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::SalePending, Some(buy.as_str())), + "purchase back in the mempool" + ); + + // Another listing of the name open by now: stays Sold. + let f = fx(ListingState::Listed); + sold(&f); + paid(&f, &buy, 2, -1, false); + let mut newer = listing(&f); + newer.id = "l2".into(); + newer.state = ListingState::Locking; + newer.lock_txid = None; + newer.lock_vout = None; + newer.lock_transfer_txid = Some(txid("e2")); + queries::insert_shakedex_listing(&f.conn, &newer).unwrap(); + // The write itself refuses it (0 rows), before the one-open-listing + // index would turn it into an error. + let pending = ListingState::SalePending; + let lock = (f.lock_txid.as_str(), 0); + assert_eq!( + queries::resell_sold_listing(&f.conn, &f.id, pending, &buy, lock).unwrap(), + 0 + ); + run(&f, &back).await; + assert_eq!( + listing(&f).state, + ListingState::Sold, + "another listing is open" + ); + + // A competing purchase of the same lock coin mined instead. + let f = fx(ListingState::Listed); + sold(&f); + let rival = txid("c7"); + paid(&f, &rival, 2, TIP + 3, false); + let won = node( + info((&rival, 0)), + vec![transfer_out_of_lock(&f, &rival, &f.buyer, TIP + 3)], + purchase_rest(&f, &rival, TIP + 3, &f.payment), + ); + run(&f, &won).await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(rival.as_str())), + "a competing purchase mined" + ); + + // The FINALIZE back in the mempool, the purchase gone. + let f = fx(ListingState::Listed); + sold(&f); + run( + &f, + &node( + info((&f.lock_txid, 0)), + vec![lock_coin(&f, -1)], + Value::Null, + ), + ) + .await; + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid), + (ListingState::Finalizing, None), + "FINALIZE in the mempool" + ); +} + +/// A Sold listing whose purchase is still the one hsd shows is not written +/// again: each sync leaves `updated_at` as it was, so the listing leaves +/// the after-lock set once [`crate::shakedex_jobs::SOLD_RECHECK_DAYS`] +/// pass, instead of being re-checked for ever. +#[tokio::test] +async fn a_stable_sold_listing_is_not_written_again() { + let f = fx(ListingState::Sold); + let buy = txid("b1"); + paid(&f, &buy, 2, TIP, false); + f.conn + .execute( + "UPDATE shakedex_listings SET sold_txid = ?1, + updated_at = datetime('now', '-6 days', '-1 hours') WHERE id = ?2", + params![buy, f.id], + ) + .unwrap(); + let before = listing(&f).updated_at; + let chain = node( + info((&buy, 0)), + vec![transfer_out_of_lock(&f, &buy, &f.buyer, TIP)], + purchase_rest(&f, &buy, TIP, &f.payment), + ); + for _ in 0..2 { + run(&f, &chain).await; + } + assert!( + chain.count_matching(|c| matches!(c, RpcCall::TxByHash(t) if *t == buy)) > 0, + "the purchase was read, so the job reached the sale" + ); + let l = listing(&f); + assert_eq!( + (l.state, l.sold_txid.as_deref()), + (ListingState::Sold, Some(buy.as_str())) + ); + assert_eq!(l.updated_at, before, "not written again"); + // A day later the window is over. + let window = crate::shakedex_jobs::SOLD_RECHECK_DAYS - 1; + assert!( + queries::list_shakedex_listings_after_lock(&f.conn, PROFILE, window) + .unwrap() + .is_empty() + ); +} diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index f458470..96f01b2 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -684,18 +684,23 @@ fn insert_draft_status(conn: &Connection, id: &str, status: &str) { .unwrap(); } -/// The two listing jobs never take the same listing, and neither takes a -/// Restored one: the named sets are disjoint, and each job's query returns -/// exactly its set when every state is in the table. +/// The two listing jobs never take the same listing; the after-lock job takes +/// Restored and Sold, the before-lock job neither: the named sets are +/// disjoint, and each job's query returns exactly its set when every state is +/// in the table. #[test] fn job_listing_sets_are_disjoint() { let before = ListingState::BEFORE_LOCK_JOB; - let fin = ListingState::FINALIZE_JOB; + let after = ListingState::AFTER_LOCK_JOB; for s in before { - assert!(!fin.contains(&s), "{s:?} in both jobs"); + assert!(!after.contains(&s), "{s:?} in both jobs"); } assert!(!before.contains(&ListingState::Restored)); - assert!(!fin.contains(&ListingState::Restored)); + assert!(after.contains(&ListingState::Restored)); + for s in queries::ListingWrite::Sell.from() { + let s = *s; + assert!(after.contains(&s), "{s:?}: a sale from it is followed"); + } for s in ListingState::CANCEL_ABORTABLE { assert!(before.contains(&s), "{s:?}"); } @@ -723,32 +728,38 @@ fn job_listing_sets_are_disjoint() { sorted(&before) ); assert_eq!( - states(queries::list_shakedex_listings_finalizing(&conn, STORE_PROFILE).unwrap()), - sorted(&fin) + states(queries::list_shakedex_listings_after_lock(&conn, STORE_PROFILE, 7).unwrap()), + sorted(&after) ); } -/// The Finalizing/Listed source lists exactly those two states of a profile. +/// The after-lock source lists exactly its five states of a profile, in +/// creation order. #[test] -fn finalizing_and_listed_listings_are_listed() { +fn after_lock_listings_are_listed() { let conn = store_conn(); for (id, name, state) in [ ("l1", "a", ListingState::Finalizing), ("l2", "b", ListingState::Listed), - ("l3", "c", ListingState::ReadyToFinalize), - ("l4", "d", ListingState::Sold), + ("l3", "c", ListingState::SalePending), + ("l4", "d", ListingState::Restored), + ("l5", "e", ListingState::Sold), + ("l6", "f", ListingState::ReadyToFinalize), + ("l7", "g", ListingState::Aborted), ] { queries::insert_shakedex_listing(&conn, &listing(id, name, state)).unwrap(); } - let ids: Vec = queries::list_shakedex_listings_finalizing(&conn, STORE_PROFILE) + let ids: Vec = queries::list_shakedex_listings_after_lock(&conn, STORE_PROFILE, 7) .unwrap() .into_iter() .map(|l| l.id) .collect(); - assert_eq!(ids, ["l1", "l2"]); - assert!(queries::list_shakedex_listings_finalizing(&conn, "other") - .unwrap() - .is_empty()); + assert_eq!(ids, ["l1", "l2", "l3", "l4", "l5"]); + assert!( + queries::list_shakedex_listings_after_lock(&conn, "other", 7) + .unwrap() + .is_empty() + ); } /// The deadline scan's source: ReadyToFinalize listings, and Finalizing ones @@ -1402,7 +1413,7 @@ async fn lock_confirmation_shows_what_locking_costs() { use crate::commands::names::build_cancel_draft; use crate::noncustodial::rpc::NodeCoin; -use crate::shakedex_jobs::refresh_listings_before_lock_with_client; +use crate::shakedex_jobs::refresh_listings_with_client; use crate::tests::mock_node_rpc::{MockNodeRpc, RpcCall}; /// Lock NAME, then make the wallet's records what a sync leaves after the @@ -1468,17 +1479,17 @@ fn chain( }) } -/// Run the abort job on the app's database, as the sync step does. The -/// connection is taken out of the app for the call, so no lock is held -/// across an await. -async fn run_abort_job(app: &App, rpc: &MockNodeRpc) { +/// Run the listing step (both listing jobs, as one sync runs them) on the +/// app's database. The connection is taken out of the app for the call, so +/// no lock is held across an await. +async fn run_listing_step(app: &App, rpc: &MockNodeRpc) { let conn = std::mem::replace( &mut *app.state::().db.lock().unwrap(), Connection::open_in_memory().unwrap(), ); - let res = refresh_listings_before_lock_with_client(&conn, rpc, PROFILE).await; + let res = refresh_listings_with_client(&conn, rpc, PROFILE).await; *app.state::().db.lock().unwrap() = conn; - res.expect("abort job runs"); + res.expect("listing step runs"); assert_eq!( rpc.count_matching(|c| matches!(c, RpcCall::SendRawTransaction(_))), 0, @@ -1523,20 +1534,20 @@ async fn cancel_transfer_aborts_the_listing() { }); // Sent, nothing on chain yet: the lock TRANSFER is still unspent. - run_abort_job(&app, &chain(&ctxid, None, &lock_txid, true)).await; + run_listing_step(&app, &chain(&ctxid, None, &lock_txid, true)).await; assert_eq!( listing_state(&app, &l.id), ListingState::Locking, "not taken" ); - run_abort_job(&app, &chain(&ctxid, Some(Some(-1)), &lock_txid, false)).await; + run_listing_step(&app, &chain(&ctxid, Some(Some(-1)), &lock_txid, false)).await; assert_eq!( listing_state(&app, &l.id), ListingState::Locking, "in the mempool" ); - run_abort_job( + run_listing_step( &app, &chain(&ctxid, Some(Some(QUIET_TIP)), &lock_txid, false), ) @@ -1544,14 +1555,14 @@ async fn cancel_transfer_aborts_the_listing() { assert_eq!(listing_state(&app, &l.id), ListingState::Aborted); assert!(open_listing(&app).is_none(), "the name is free again"); - run_abort_job(&app, &chain(&ctxid, Some(Some(-1)), &lock_txid, false)).await; + run_listing_step(&app, &chain(&ctxid, Some(Some(-1)), &lock_txid, false)).await; assert_eq!( listing_state(&app, &l.id), ListingState::Locking, "a reorg took the cancel back to the mempool" ); - run_abort_job( + run_listing_step( &app, &chain(&ctxid, Some(Some(QUIET_TIP)), &lock_txid, false), ) @@ -1561,7 +1572,7 @@ async fn cancel_transfer_aborts_the_listing() { ListingState::Aborted, "mined again" ); - run_abort_job(&app, &chain(&ctxid, None, &lock_txid, true)).await; + run_listing_step(&app, &chain(&ctxid, None, &lock_txid, true)).await; assert_eq!( listing_state(&app, &l.id), ListingState::Locking, @@ -1595,7 +1606,7 @@ async fn abort_needs_the_nodes_word() { ("both spent", chain(&ctxid, None, &lock_txid, false)), ("no answer", MockNodeRpc::new()), ] { - run_abort_job(&app, &rpc).await; + run_listing_step(&app, &rpc).await; assert_eq!(listing_state(&app, &id), state, "{what}"); } } @@ -1680,7 +1691,7 @@ async fn deleted_dropped_cancel_mined_later_still_aborts() { assert!(queries::get_tx_draft(c, &cancel.id).unwrap().is_none()); }); - run_abort_job( + run_listing_step( &app, &chain(&ctxid, Some(Some(QUIET_TIP)), &lock_txid, false), ) @@ -1702,7 +1713,7 @@ async fn reorged_abort_leaves_a_newer_listing_open() { .unwrap(); let ctxid = cancel.summary["txid"].as_str().unwrap().to_string(); let old = open_listing(&app).unwrap().id; - run_abort_job( + run_listing_step( &app, &chain(&ctxid, Some(Some(QUIET_TIP)), &lock_txid, false), ) @@ -1715,7 +1726,7 @@ async fn reorged_abort_leaves_a_newer_listing_open() { queries::insert_shakedex_listing(c, &newer).unwrap() }); - run_abort_job(&app, &chain(&ctxid, Some(Some(-1)), &lock_txid, false)).await; + run_listing_step(&app, &chain(&ctxid, Some(Some(-1)), &lock_txid, false)).await; assert_eq!(listing_state(&app, &old), ListingState::Aborted); assert_eq!(open_listing(&app).unwrap().id, "newer"); // Refused by the guard, not by the index: no error for the job to log. @@ -1857,7 +1868,7 @@ async fn external_cancel_aborts_the_listing() { let id = open_listing(&app).unwrap().id; assert_eq!(open_listing(&app).unwrap().abort_txid, None); - run_abort_job( + run_listing_step( &app, &facts( info_with(&lock_txid, 0, 0), @@ -1868,7 +1879,7 @@ async fn external_cancel_aborts_the_listing() { assert_eq!(listing_state(&app, &id), ListingState::Locking); let other_cancel = "ee".repeat(32); - run_abort_job( + run_listing_step( &app, &facts( info_with(&other_cancel, 0, 0), @@ -1888,9 +1899,9 @@ async fn revoked_name_aborts_the_listing() { let lock_txid = locked_on_chain(&app).await; let id = open_listing(&app).unwrap().id; let revoked = facts(info_with(&lock_txid, 0, QUIET_TIP as u64), &[]); - run_abort_job(&app, &revoked).await; + run_listing_step(&app, &revoked).await; assert_eq!(listing_state(&app, &id), ListingState::Aborted); - run_abort_job(&app, &revoked).await; + run_listing_step(&app, &revoked).await; assert_eq!(listing_state(&app, &id), ListingState::Aborted, "stays"); } @@ -1911,7 +1922,7 @@ async fn dead_lock_draft_aborts_and_the_name_can_be_locked_again() { queries::delete_tx_draft(c, &lock.id).unwrap(); } }); - run_abort_job( + run_listing_step( &app, &facts( info_with(OWNER_TXID, 0, 0), @@ -1945,7 +1956,7 @@ async fn replaced_cancel_aborts_the_listing() { assert_eq!(l.abort_draft_id.as_deref(), Some(b.id.as_str())); assert_ne!(l.abort_txid.as_deref(), Some(a_txid.as_str())); - run_abort_job( + run_listing_step( &app, &facts(info_with(&a_txid, 0, 0), &[(&a_txid, 0, COV_UPDATE)]), ) @@ -1966,7 +1977,7 @@ async fn lock_draft_in_flight_without_its_coin_changes_nothing() { queries::update_tx_draft_status(c, &lock.id, status, None, None).unwrap(); }); } - run_abort_job( + run_listing_step( &app, &facts( info_with(OWNER_TXID, 0, 0), @@ -1990,7 +2001,7 @@ async fn name_without_live_state_expires_the_listing() { &[(&lock_txid, 0, COV_TRANSFER)], ); set_state(&app, &id, ListingState::Aborted); - run_abort_job(&app, &gone).await; + run_listing_step(&app, &gone).await; assert_eq!(listing_state(&app, &id), ListingState::Aborted); with_db(&app, |c| { assert_eq!( @@ -2000,7 +2011,7 @@ async fn name_without_live_state_expires_the_listing() { ); }); set_state(&app, &id, ListingState::Locking); - run_abort_job(&app, &gone).await; + run_listing_step(&app, &gone).await; assert_eq!(listing_state(&app, &id), ListingState::Expired); } @@ -2027,13 +2038,13 @@ async fn aborted_listing_relocks_when_the_lock_transfer_is_back() { ("the owner again", facts(info_with(&lock_txid, 0, 0), &[])), ] { set_state(&app, &id, ListingState::Aborted); - run_abort_job(&app, &rpc).await; + run_listing_step(&app, &rpc).await; assert_eq!(listing_state(&app, &id), ListingState::Locking, "{what}"); } // The lock TRANSFER is output 0: another output of its transaction is // not it. set_state(&app, &id, ListingState::Aborted); - run_abort_job(&app, &facts(info_with(&lock_txid, 1, 0), &[])).await; + run_listing_step(&app, &facts(info_with(&lock_txid, 1, 0), &[])).await; assert_eq!(listing_state(&app, &id), ListingState::Aborted, "output 1"); } @@ -2092,7 +2103,7 @@ async fn missing_chain_facts_change_nothing() { )); for (what, rpc) in cases { set_state(&app, &id, state); - run_abort_job(&app, &rpc).await; + run_listing_step(&app, &rpc).await; assert_eq!(listing_state(&app, &id), state, "{state:?}: {what}"); } } @@ -3405,30 +3416,6 @@ async fn listing_file_is_exported_only_once_the_finalize_is_mined() { // --- The listing's states from the chain (T3, R19) -------------------------- -use crate::shakedex_jobs::refresh_lock_finalize_with_client; - -/// Run the finalize job on the app's database, as the sync step does. -async fn run_finalize_job(app: &App, rpc: &MockNodeRpc) { - let conn = std::mem::replace( - &mut *app.state::().db.lock().unwrap(), - Connection::open_in_memory().unwrap(), - ); - let res = refresh_lock_finalize_with_client(&conn, rpc, PROFILE).await; - *app.state::().db.lock().unwrap() = conn; - res.expect("finalize job runs"); - assert_eq!( - rpc.count_matching(|c| matches!(c, RpcCall::SendRawTransaction(_))), - 0, - "the job sends nothing (it runs in the daemon too)" - ); -} - -/// Run both listing jobs in the order `run_sync_steps` runs them. -async fn run_listing_jobs(app: &App, rpc: &MockNodeRpc) { - run_abort_job(app, rpc).await; - run_finalize_job(app, rpc).await; -} - fn tip_info(tip: i64) -> crate::noncustodial::rpc::BlockchainInfo { serde_json::from_value(json!({ "blocks": tip, "headers": tip, "mediantime": SIGN_MTP })) .unwrap() @@ -3447,10 +3434,45 @@ fn cov_action(cov_type: u8) -> &'static str { /// A coin as hsd's `GET /coin` sends it (`Coin.getJSON`: version, height, /// value, address, covenant with type, action and items, coinbase, hash, -/// index), with address, covenant type and height (-1 in the mempool). +/// index), with address, covenant type and height (-1 in the mempool); its +/// covenant's first items are NAME's hash and name height, as every name +/// covenant's are. fn coin_json(txid: &str, vout: u32, address: &str, cov_type: u8, height: i64) -> Value { + coin_json_of(NAME, txid, vout, address, cov_type, height) +} + +/// [`coin_json`] for a covenant of `name`. +fn coin_json_of( + name: &str, + txid: &str, + vout: u32, + address: &str, + cov_type: u8, + height: i64, +) -> Value { + let nh = hex::encode(crate::noncustodial::names::hash_name(name).unwrap()); + let h = hex::encode(NAME_HEIGHT.to_le_bytes()); + // hsd's items per covenant (`rules.js`): REGISTER [hash, height, record, + // block hash], UPDATE [hash, height, record], TRANSFER [hash, height, + // address version, address hash], FINALIZE [hash, height, raw name, + // flags, claimed, renewals, block hash]. + let items: Vec = match cov_type { + COV_REGISTER => vec![nh, h, String::new(), "ab".repeat(32)], + COV_UPDATE => vec![nh, h, String::new()], + COV_TRANSFER => vec![nh, h, "00".into(), "09".repeat(20)], + COV_FINALIZE => vec![ + nh, + h, + hex::encode(name.as_bytes()), + "00".into(), + "00000000".into(), + "00000000".into(), + "ab".repeat(32), + ], + other => panic!("no covenant items for type {other} in these tests"), + }; json!({ "version": 0, "height": height, "value": NAME_VALUE, "address": address, - "covenant": { "type": cov_type, "action": cov_action(cov_type), "items": [] }, + "covenant": { "type": cov_type, "action": cov_action(cov_type), "items": items }, "coinbase": false, "hash": txid, "index": vout }) } @@ -3541,13 +3563,13 @@ async fn lockup_over_makes_the_listing_ready_to_finalize() { let coin = vec![lock_transfer_at(&lock_txid, TRANSFER_HEIGHT)]; let tip = ready_tip(Network::Regtest); - run_abort_job(&app, &chain_at(info.clone(), tip - 1, coin.clone())).await; + run_listing_step(&app, &chain_at(info.clone(), tip - 1, coin.clone())).await; assert_eq!( listing_state(&app, &id), ListingState::Locking, "one block early" ); - run_abort_job(&app, &chain_at(info, tip, coin)).await; + run_listing_step(&app, &chain_at(info, tip, coin)).await; assert_eq!(listing_state(&app, &id), ListingState::ReadyToFinalize); } @@ -3592,14 +3614,14 @@ async fn ready_listing_goes_back_to_locking_on_a_reorg() { ListingState::ReadyToFinalize, "{case}: before" ); - run_abort_job(&app, &chain).await; + run_listing_step(&app, &chain).await; assert_eq!(listing_state(&app, &id), want, "{case}"); } } /// Coordinator (a)+(b): once Finalize & sign ran, the FINALIZE mined into /// our lock (owner the lock coin, lock TRANSFER 404, lock draft confirmed — -/// T2's abort picture) is Listed by the finalize job and never Aborted by +/// T2's abort picture) is Listed by the after-lock job and never Aborted by /// the before-lock job, which runs first in the same sync. #[tokio::test] async fn finalize_into_our_lock_is_never_an_abort() { @@ -3620,15 +3642,15 @@ async fn finalize_into_our_lock_is_never_an_abort() { tip, )], ); - run_abort_job(&r.app, &chain).await; + // The before-lock job runs first in the sync and leaves it to the + // after-lock job, which lists it. + run_listing_step(&r.app, &chain).await; assert_eq!( listing_state(&r.app, &r.listing_id), - ListingState::Finalizing, + ListingState::Listed, "not Aborted" ); - run_finalize_job(&r.app, &chain).await; - assert_eq!(listing_state(&r.app, &r.listing_id), ListingState::Listed); - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; assert_eq!( listing_state(&r.app, &r.listing_id), ListingState::Listed, @@ -3682,7 +3704,7 @@ async fn finalize_into_our_lock_from_another_device_is_a_restored_lock() { ListingState::ReadyToFinalize, "{case}: before" ); - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; let l = r.listing(); assert_eq!(l.state, want, "{case}"); if want == ListingState::Restored { @@ -3695,6 +3717,51 @@ async fn finalize_into_our_lock_from_another_device_is_a_restored_lock() { } } +/// A coin at our lock address that is a covenant of another name (every +/// lock coin of a key sits at one address, ADR 0004) is never this +/// listing's: a FINALIZE of another name as the owner coin does not make a +/// Restored lock, and as the lock coin it does not make a Finalizing listing +/// Listed. +#[tokio::test] +async fn a_coin_of_another_name_at_our_lock_is_never_adopted_or_listed() { + let tip = ready_tip(Network::Regtest) + 1; + let at_lock = lock_address(Network::Regtest); + let other_name = |txid: &str, vout: u32| -> NodeCoin { + serde_json::from_value(coin_json_of( + "othername", + txid, + vout, + &at_lock, + COV_FINALIZE, + tip, + )) + .unwrap() + }; + // Before the lock: the owner coin. + let r = ready_fixture("regtest").await; + set_lock_draft_status(&r.app, &r.listing_id, "confirmed"); + let other = "0e".repeat(32); + let chain = chain_at( + name_info(RENEWAL, 0, &other), + tip, + vec![other_name(&other, 0)], + ); + run_listing_step(&r.app, &chain).await; + let l = r.listing(); + assert_eq!( + (l.state, l.lock_txid), + (ListingState::ReadyToFinalize, None), + "not adopted" + ); + // After the lock: the lock coin. + let r = ready_fixture("regtest").await; + let (fin, vout) = finalized(&r).await; + let mut info = name_info(RENEWAL, 0, &fin); + info["info"]["owner"]["index"] = vout.into(); + run_listing_step(&r.app, &chain_at(info, tip, vec![other_name(&fin, vout)])).await; + assert_eq!(r.listing().state, ListingState::Finalizing, "not listed"); +} + /// Deviation 3 with the jobs in sync order: a FINALIZE that was dropped /// returns the listing to ReadyToFinalize; if that FINALIZE is mined after /// all, the name sits in our lock under an outpoint the listing no longer @@ -3714,7 +3781,7 @@ async fn dropped_finalize_mined_after_all_is_a_restored_lock() { tip, vec![lock_transfer_at(&r.lock_txid, TRANSFER_HEIGHT)], ); - run_listing_jobs(&r.app, &dropped).await; + run_listing_step(&r.app, &dropped).await; assert_eq!( listing_state(&r.app, &r.listing_id), ListingState::ReadyToFinalize @@ -3733,7 +3800,7 @@ async fn dropped_finalize_mined_after_all_is_a_restored_lock() { tip + 1, )], ); - run_listing_jobs(&r.app, &mined).await; + run_listing_step(&r.app, &mined).await; let l = r.listing(); assert_eq!(l.state, ListingState::Restored); assert_eq!( @@ -3817,7 +3884,7 @@ async fn mined_finalize_lists_the_listing_and_a_reorg_takes_it_back() { mined + 2, vec![coin_at(&fin, vout, addr, cov, height)], ); - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; assert_eq!(listing_state(&r.app, &r.listing_id), want, "{case}"); } } @@ -3881,7 +3948,7 @@ async fn refused_finalize_returns_the_listing_to_ready() { } else { vec![] }; - run_finalize_job( + run_listing_step( &r.app, &chain_at(r.info(), ready_tip(Network::Regtest) + 1, coins), ) @@ -3925,7 +3992,7 @@ async fn finalize_facts_missing_change_nothing() { ("lock coin without covenant", strip("covenant")), ("lock coin without height", strip("height")), ] { - run_listing_jobs(&r.app, &chain_at(r.info(), tip, vec![coin])).await; + run_listing_step(&r.app, &chain_at(r.info(), tip, vec![coin])).await; assert_eq!( listing_state(&r.app, &r.listing_id), ListingState::Finalizing, @@ -3936,7 +4003,7 @@ async fn finalize_facts_missing_change_nothing() { .with_name_info(r.info()) .with_blockchain_info(tip_info(tip)) .with_get_coin(|_, _| Err(AppError::Rpc("down".into()))); - run_listing_jobs(&r.app, &coin_err).await; + run_listing_step(&r.app, &coin_err).await; assert_eq!( listing_state(&r.app, &r.listing_id), ListingState::Finalizing, @@ -3955,7 +4022,7 @@ async fn finalize_facts_missing_change_nothing() { Err(AppError::Rpc("down".into())) } }); - run_finalize_job(&r.app, &transfer_err).await; + run_listing_step(&r.app, &transfer_err).await; assert_eq!( listing_state(&r.app, &r.listing_id), ListingState::Finalizing, @@ -3998,7 +4065,7 @@ async fn finalize_facts_missing_change_nothing() { }), ), ] { - run_abort_job(&app, &chain).await; + run_listing_step(&app, &chain).await; assert_eq!(listing_state(&app, &id), ListingState::Locking, "{case}"); } @@ -4036,7 +4103,7 @@ async fn finalize_facts_missing_change_nothing() { }), ), ] { - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; assert_eq!( listing_state(&r.app, &r.listing_id), ListingState::ReadyToFinalize, @@ -4136,7 +4203,7 @@ async fn owner_coin_spent_in_the_mempool_is_no_verdict() { set_lock_draft_status(&app, &id, "confirmed"); let other = "0e".repeat(32); let chain = chain_at(name_info(RENEWAL, 0, &other), QUIET_TIP, vec![]); - run_listing_jobs(&app, &chain).await; + run_listing_step(&app, &chain).await; assert_eq!(listing_state(&app, &id), state, "{state:?}"); } } @@ -4160,7 +4227,7 @@ async fn mined_lock_transfer_that_is_not_the_owner_changes_nothing() { ready_tip(Network::Regtest), vec![lock_transfer_at(&lock_txid, TRANSFER_HEIGHT)], ); - run_abort_job(&app, &chain).await; + run_listing_step(&app, &chain).await; assert_eq!(listing_state(&app, &id), state, "{state:?}: mined"); let no_height = chain_at( name_info(RENEWAL, 0, OWNER_TXID), @@ -4174,11 +4241,348 @@ async fn mined_lock_transfer_that_is_not_the_owner_changes_nothing() { "height", )], ); - run_abort_job(&app, &no_height).await; + run_listing_step(&app, &no_height).await; assert_eq!(listing_state(&app, &id), state, "{state:?}: no height"); } } +/// T3 carry: the name expired and was opened again while the lock TRANSFER +/// is still a coin. hsd's owner is the new auction's, and the TRANSFER is +/// mined but not the owner — which alone is no consistent answer; but its +/// covenant commits to the name height of a registration hsd no longer +/// has (`info.height` is the new auction's), so the listing is Expired, not +/// an error on every sync. The same TRANSFER with hsd's own name height +/// stays no verdict. +#[tokio::test] +async fn reopened_name_expires_a_listing_before_the_lock() { + for state in [ListingState::Locking, ListingState::ReadyToFinalize] { + let (_node, _m, app) = lock_fixture("regtest", "mnemonic_hot", QUIET_TIP).await; + let lock_txid = locked_on_chain(&app).await; + let id = open_listing(&app).unwrap().id; + set_state(&app, &id, state); + let program = derive_lock_key(&master(), Network::Regtest, 0, NAME) + .unwrap() + .program; + let transfer: NodeCoin = serde_json::from_value(lock_transfer_coin( + Network::Regtest, + &lock_txid, + &program, + TRANSFER_HEIGHT, + )) + .unwrap(); + let mut same = name_info(RENEWAL, 0, &"0e".repeat(32)); + run_listing_step( + &app, + &chain_at(same.clone(), QUIET_TIP, vec![transfer.clone()]), + ) + .await; + assert_eq!( + listing_state(&app, &id), + state, + "{state:?}: same registration" + ); + same["info"]["height"] = 7_000.into(); + run_listing_step(&app, &chain_at(same, QUIET_TIP, vec![transfer])).await; + assert_eq!( + listing_state(&app, &id), + ListingState::Expired, + "{state:?}: reopened" + ); + } +} + +/// hsd's `getblock true true` holding the one transaction `rest` +/// (hsd's `GET /tx` shape) at `height`. +fn block_holding(rest: &Value, height: i64) -> Value { + let vin: Vec<_> = rest["inputs"] + .as_array() + .unwrap() + .iter() + .map(|i| json!({ "txid": i["prevout"]["hash"], "vout": i["prevout"]["index"] })) + .collect(); + let vout: Vec<_> = rest["outputs"] + .as_array() + .unwrap() + .iter() + .enumerate() + .map(|(n, o)| { + json!({ "n": n, "value": 0.0, + "address": { "version": 0, "hash": "00", "string": o["address"] }, + "covenant": o["covenant"] }) + }) + .collect(); + json!({ "height": height, "tx": [ { "txid": rest["hash"], "vin": vin, "vout": vout } ] }) +} + +/// How hsd shows the FINALIZE whose lock coin a purchase spent, in +/// [`dropped_finalize_mined_and_bought_before_a_sync_is_sold`]. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FinalizeSeen { + /// Our FINALIZE into the lock, spending this listing's lock TRANSFER. + Ours, + /// A FINALIZE at the lock address spending another coin (another + /// listing's lock TRANSFER of the name). + OtherTransfer, + /// Our lock TRANSFER spent into an output that is not a FINALIZE. + NotFinalize, + /// A FINALIZE spending our lock TRANSFER, of another name. + OtherName, + /// A FINALIZE spending our lock TRANSFER, not at the lock address. + OtherAddress, + /// Our FINALIZE, but `GET /tx` shows it in the mempool: not hsd's + /// picture of a coin a mined purchase spent. + InMempool, + /// Our FINALIZE, and before it (a later coin of ours at the payment + /// address) a purchase out of our lock address of a FINALIZE that spends + /// another lock TRANSFER: that lead is not proven, the next one is. + WrongLeadFirst, + /// [`Self::WrongLeadFirst`], but that lead's FINALIZE cannot be read: it + /// is skipped, not the end of the search. + UnreadableLeadFirst, +} + +/// T3 carry: our FINALIZE was dropped (the listing back at ReadyToFinalize +/// without its outpoint), mined after all, and the lock coin it made bought +/// before a sync saw any of it. The listing is Sold only once hsd shows the +/// coin the purchase spent as a FINALIZE at this listing's lock address +/// spending this listing's lock TRANSFER, and with that coin as its lock +/// outpoint — read with the index (`GET /tx`) or without it (the FINALIZE's +/// block at `info.renewal`, the purchase's at our coin's height). With the +/// purchase's TRANSFER still the owner or the buyer's FINALIZE the owner; +/// never Aborted. Also for a listing still Locking (this device was offline +/// through the lockup; the lock was finalized elsewhere and bought). Every +/// mined lead is tried until one is proven. A purchase whose spent coin hsd +/// cannot show (the buyer finalized, no index), or shows as anything else, +/// leaves the listing as it is. +#[tokio::test] +async fn dropped_finalize_mined_and_bought_before_a_sync_is_sold() { + use FinalizeSeen::*; + use ListingState::{Locking, ReadyToFinalize}; + let cases = [ + // (buyer finalized, index, how the FINALIZE is shown, from, Sold) + (false, true, Ours, ReadyToFinalize, true), + (true, true, Ours, ReadyToFinalize, true), + (false, false, Ours, ReadyToFinalize, true), + (true, false, Ours, ReadyToFinalize, false), + (false, true, OtherTransfer, ReadyToFinalize, false), + (true, true, NotFinalize, ReadyToFinalize, false), + (false, true, InMempool, ReadyToFinalize, false), + (false, true, OtherName, ReadyToFinalize, false), + (false, true, OtherAddress, ReadyToFinalize, false), + (false, true, Ours, Locking, true), + (true, true, Ours, Locking, true), + (true, true, WrongLeadFirst, ReadyToFinalize, true), + (true, true, UnreadableLeadFirst, ReadyToFinalize, true), + ]; + for (buyer_finalized, index, seen, from, sold) in cases { + let what = + format!("buyer finalized {buyer_finalized}, index {index}, {seen:?}, from {from:?}"); + let r = ready_fixture("regtest").await; + let (fin, vout) = finalized(&r).await; + set_lock_draft_status(&r.app, &r.listing_id, "confirmed"); + let fin_draft = r.listing().lock_finalize_draft_id.unwrap(); + with_db(&r.app, |c| { + queries::update_tx_draft_status(c, &fin_draft, "dropped", None, Some(&fin)).unwrap(); + }); + let tip = ready_tip(Network::Regtest) + 1; + run_listing_step( + &r.app, + &chain_at( + r.info(), + tip, + vec![lock_transfer_at(&r.lock_txid, TRANSFER_HEIGHT)], + ), + ) + .await; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::ReadyToFinalize + ); + assert_eq!( + r.listing().lock_txid, + None, + "the outpoint went with the FINALIZE" + ); + set_state(&r.app, &r.listing_id, from); + + let payment = r.listing().payment_address.unwrap(); + let lock = lock_address(Network::Regtest); + let buy = "b1".repeat(32); + let buyer = address::encode_p2wpkh(Network::Regtest, &[9; 20]).unwrap(); + let nh = hex::encode(crate::noncustodial::names::hash_name(NAME).unwrap()); + let items = vec![ + nh.clone(), + hex::encode(NAME_HEIGHT.to_le_bytes()), + "00".into(), + hex::encode([9u8; 20]), + ]; + with_db(&r.app, |c| { + c.execute( + "INSERT INTO tracked_utxos (txid, vout, wallet_profile_id, address, + script_pubkey_hex, value_doos, height, covenant_type, spend_class, + spent_by_txid) + VALUES (?1, 2, ?2, ?3, '00', 5000000, ?4, 0, 'liquid_hns', NULL)", + params![buy, PROFILE, payment, tip + 1], + ) + .unwrap(); + }); + let plain = |to: &str, value: u64| { + json!({ "value": value, "address": to, + "covenant": { "type": 0, "action": "NONE", "items": [] } }) + }; + let none = || plain(&buyer, 1); + let rest = json!({ "hash": buy, "height": tip + 1, "hex": "00", + "inputs": [ { "prevout": { "hash": fin, "index": vout } } ], + "outputs": [ + { "value": NAME_VALUE, "address": lock, + "covenant": { "type": COV_TRANSFER, "action": "TRANSFER", "items": items } }, + none(), + plain(&payment, 5_000_000) + ] }); + // The FINALIZE: input `vout` spends the lock TRANSFER (or another + // coin), output `vout` is the lock coin (or not a FINALIZE). + let spent = if seen == OtherTransfer { + "e9".repeat(32) + } else { + r.lock_txid.clone() + }; + let mut fin_inputs = + vec![json!({ "prevout": { "hash": "aa".repeat(32), "index": 1 } }); vout as usize + 1]; + fin_inputs[vout as usize] = json!({ "prevout": { "hash": spent, "index": 0 } }); + let mut fin_outputs = vec![none(); vout as usize + 1]; + let (cov, action) = if seen == NotFinalize { + (COV_UPDATE, "UPDATE") + } else { + (COV_FINALIZE, "FINALIZE") + }; + let fin_name = if seen == OtherName { + "ab".repeat(32) + } else { + nh.clone() + }; + let fin_to = if seen == OtherAddress { + buyer.clone() + } else { + lock.clone() + }; + fin_outputs[vout as usize] = json!({ "value": NAME_VALUE, "address": fin_to, + "covenant": { "type": cov, "action": action, + "items": [fin_name, hex::encode(NAME_HEIGHT.to_le_bytes())] } }); + let fin_height = if seen == InMempool { -1 } else { tip }; + let fin_rest = json!({ "hash": fin, "height": fin_height, "hex": "00", + "inputs": fin_inputs, "outputs": fin_outputs }); + + let (owner, coins) = if buyer_finalized { + let y = "a9".repeat(32); + ( + y.clone(), + vec![coin_at(&y, 0, &buyer, COV_FINALIZE, tip + 12)], + ) + } else { + let mut t: Value = coin_json(&buy, 0, &lock, COV_TRANSFER, tip + 1); + t["covenant"]["items"] = rest["outputs"][0]["covenant"]["items"].clone(); + (buy.clone(), vec![serde_json::from_value(t).unwrap()]) + }; + // hsd's FINALIZE sets `renewal` to its block; a TRANSFER leaves it. + let mut info = name_info(RENEWAL, 0, &owner); + if !buyer_finalized { + info["info"]["renewal"] = tip.into(); + } + let mut txs = vec![(buy.clone(), rest.clone()), (fin.clone(), fin_rest.clone())]; + if matches!(seen, WrongLeadFirst | UnreadableLeadFirst) { + // A later coin of ours at the payment address: a purchase out + // of our lock address of `f9…:0`, a FINALIZE at the lock that + // spends another lock TRANSFER. + let (wrong, wrong_fin) = ("c3".repeat(32), "f9".repeat(32)); + with_db(&r.app, |c| { + c.execute( + "INSERT INTO tracked_utxos (txid, vout, wallet_profile_id, address, + script_pubkey_hex, value_doos, height, covenant_type, spend_class, + spent_by_txid) + VALUES (?1, 2, ?2, ?3, '00', 5000000, ?4, 0, 'liquid_hns', NULL)", + params![wrong, PROFILE, payment, tip + 2], + ) + .unwrap(); + }); + let mut wrong_rest = rest.clone(); + wrong_rest["hash"] = wrong.clone().into(); + wrong_rest["height"] = (tip + 2).into(); + wrong_rest["inputs"][0]["prevout"] = json!({ "hash": wrong_fin, "index": 0 }); + let mut wrong_fin_rest = fin_rest.clone(); + wrong_fin_rest["hash"] = wrong_fin.clone().into(); + wrong_fin_rest["inputs"][vout as usize]["prevout"]["hash"] = "e9".repeat(32).into(); + txs.push((wrong, wrong_rest)); + txs.push((wrong_fin, wrong_fin_rest)); + } + let blocks = [ + (format!("{:064x}", tip + 1), block_holding(&rest, tip + 1)), + (format!("{tip:064x}"), block_holding(&fin_rest, tip)), + ]; + let chain = chain_at(info, tip + 13, coins) + .with_tx_by_hash_fn(move |h| { + if seen == UnreadableLeadFirst && h == "f9".repeat(32) { + return Err(crate::error::AppError::Rpc("connection reset".into())); + } + Ok(match txs.iter().find(|(t, _)| *t == h) { + Some((_, v)) if index => v.clone(), + _ => Value::Null, + }) + }) + .with_block_hash_fn(|height| Ok(format!("{height:064x}"))) + .with_block_fn(move |h| { + Ok(blocks + .iter() + .find(|(b, _)| *b == h) + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| json!({ "height": 1, "tx": [] }))) + }); + run_listing_step(&r.app, &chain).await; + let l = r.listing(); + if sold { + assert_eq!(l.state, ListingState::Sold, "{what}"); + assert_eq!(l.sold_txid.as_deref(), Some(buy.as_str()), "{what}"); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(fin.as_str()), Some(i64::from(vout))), + "{what}" + ); + } else { + assert_eq!(l.state, from, "{what}"); + assert_eq!((l.sold_txid, l.lock_txid), (None, None), "{what}"); + } + } +} + +/// The T2 carry: a Locking listing whose lock TRANSFER draft is dead +/// (dropped, failed, deleted) still holds the name's one open listing until +/// the next sync ends it from the chain. Lock says so, instead of "already +/// locked for sale"; after that sync it builds. +#[tokio::test] +async fn lock_refused_while_a_dead_lock_waits_for_the_sync() { + let (_node, _m, app) = lock_fixture("regtest", "mnemonic_hot", QUIET_TIP).await; + let lock = build(&app).await.expect("lock builds"); + let e = build(&app).await.unwrap_err(); + assert!(err_text(e).contains("already locked for sale")); + with_db(&app, |c| { + queries::update_tx_draft_status(c, &lock.id, "dropped", None, None).unwrap(); + queries::release_reserved_utxos_for_draft(c, &lock.id).unwrap(); + }); + let e = err_text(build(&app).await.unwrap_err()); + assert!( + e.contains("did not go through") && e.contains("next sync"), + "{e}" + ); + run_listing_step( + &app, + &facts( + info_with(OWNER_TXID, 0, 0), + &[(OWNER_TXID, 0, COV_REGISTER)], + ), + ) + .await; + build(&app).await.expect("a new lock after the sync"); +} + /// The chain once something other than our FINALIZE spent the lock /// TRANSFER: `info` as hsd answers, the lock TRANSFER and our lock coin /// hsd's 404, and `owner` (when given) a coin mined at `address` with @@ -4229,7 +4633,7 @@ async fn dead_finalize_with_its_lock_transfer_spent_elsewhere_is_resolved_from_t info_with(&cancel, 0, 0), Some((&cancel, 0, &ours, COV_UPDATE)), ); - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; let l = r.listing(); assert_eq!(l.state, ListingState::Aborted, "cancel mined, {status}"); assert_eq!( @@ -4245,7 +4649,7 @@ async fn dead_finalize_with_its_lock_transfer_spent_elsewhere_is_resolved_from_t info_with(&other_fin, 0, 0), Some((&other_fin, 0, &lock, COV_FINALIZE)), ); - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; let l = r.listing(); assert_eq!(l.state, ListingState::Restored); assert_eq!( @@ -4264,12 +4668,12 @@ async fn dead_finalize_with_its_lock_transfer_spent_elsewhere_is_resolved_from_t // A REVOKE: `owner` stays at the lock TRANSFER, `revoked` is set. let r = finalizing_with("failed").await; let chain = left_the_lock(info_with(&r.lock_txid, 0, QUIET_TIP as u64), None); - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; assert_eq!(r.listing().state, ListingState::Aborted, "revoked"); // The name expired. let r = finalizing_with("failed").await; - run_listing_jobs(&r.app, &left_the_lock(json!({ "info": null }), None)).await; + run_listing_step(&r.app, &left_the_lock(json!({ "info": null }), None)).await; assert_eq!(r.listing().state, ListingState::Expired, "info null"); // No verdict: the owner coin is spent in the mempool (404), our @@ -4291,13 +4695,13 @@ async fn dead_finalize_with_its_lock_transfer_spent_elsewhere_is_resolved_from_t ), ] { let r = finalizing_with(status).await; - run_listing_jobs(&r.app, &left_the_lock(info, owner)).await; + run_listing_step(&r.app, &left_the_lock(info, owner)).await; let l = r.listing(); assert_eq!(l.state, ListingState::Finalizing, "{case}"); assert_ne!(l.steps_json, "[]", "{case}"); } let r = finalizing_with("failed").await; - run_listing_jobs(&r.app, &left_the_lock(r.info(), None)).await; + run_listing_step(&r.app, &left_the_lock(r.info(), None)).await; assert_eq!( r.listing().state, ListingState::Finalizing, @@ -4316,15 +4720,847 @@ async fn reorged_abort_of_a_dead_finalize_relocks_without_its_steps() { info_with(&cancel, 0, 0), Some((&cancel, 0, &addr00(net).0, COV_UPDATE)), ); - run_listing_jobs(&r.app, &chain).await; + run_listing_step(&r.app, &chain).await; assert_eq!(r.listing().state, ListingState::Aborted); let back = chain_at( r.info(), ready_tip(net) + 5, vec![lock_transfer_at(&r.lock_txid, TRANSFER_HEIGHT)], ); - run_listing_jobs(&r.app, &back).await; + run_listing_step(&r.app, &back).await; let l = r.listing(); assert_eq!(l.state, ListingState::Locking); assert_eq!((l.lock_txid, l.steps_json.as_str()), (None, "[]")); } + +// --- Restore a lock by name (T4, R32) ---------------------------------------- + +use crate::commands::shakedex::shakedex_restore_lock; + +const RESTORED_TXID: &str = "7777777777777777777777777777777777777777777777777777777777777777"; + +/// The owner coin hsd reports for NAME at `(RESTORED_TXID, 1)`: `cov` at +/// `address`, committing (TRANSFER) to `committed`, or carrying the FINALIZE +/// items with name height `height`. +fn restored_coin(address: &str, cov: u8, height: u32, committed: Option<&[u8]>) -> Value { + let nh = hex::encode(crate::noncustodial::names::hash_name(NAME).unwrap()); + let items: Vec = match committed { + Some(program) => vec![ + nh, + hex::encode(height.to_le_bytes()), + "00".into(), + hex::encode(program), + ], + None => vec![ + nh, + hex::encode(height.to_le_bytes()), + hex::encode(NAME), + "00".into(), + "00000000".into(), + "00000000".into(), + RENEWAL_BLOCK.into(), + ], + }; + json!({ "hash": RESTORED_TXID, "index": 1, "value": NAME_VALUE, "address": address, + "height": QUIET_TIP - 5, "coinbase": false, "version": 0, + "covenant": { "type": cov, "action": cov_action(cov), "items": items } }) +} + +/// The FINALIZE at our lock that a restore adopts. +fn our_lock_coin() -> Value { + restored_coin( + &lock_address(Network::Regtest), + COV_FINALIZE, + NAME_HEIGHT, + None, + ) +} + +/// A fresh profile from the same phrase (no listing, no owned name) of +/// `kind`, unlocked, and a node answering its tip (`mocks[0]`), `info` for +/// NAME (`mocks[1]`) and `coin` for `(RESTORED_TXID, 1)` (`mocks[2]`; +/// `None`: hsd's 404). +async fn restore_fixture_of( + kind: &str, + info: Value, + coin: Option, +) -> (ServerGuard, Vec, App) { + let mut node = mockito::Server::new_async().await; + let mut mocks = vec![ + mock_blockchain_info(&mut node, QUIET_TIP, Some(1_700_000_000)).await, + mock_name_info(&mut node, info).await, + ]; + let path = format!("/coin/{RESTORED_TXID}/1"); + mocks.push(match coin { + Some(c) => { + node.mock("GET", path.as_str()) + .with_header("content-type", "application/json") + .with_body(c.to_string()) + .create_async() + .await + } + None => { + node.mock("GET", path.as_str()) + .with_status(404) + .create_async() + .await + } + }); + let app = app_with(seeded("regtest", kind, &node.url())); + unlock(&app, Network::Regtest); + (node, mocks, app) +} + +async fn restore_fixture(info: Value, coin: Option) -> (ServerGuard, Vec, App) { + restore_fixture_of("mnemonic_hot", info, coin).await +} + +/// `getnameinfo` naming `(RESTORED_TXID, 1)` the owner of NAME. +fn restored_info() -> Value { + let mut v = name_info(RENEWAL, 0, RESTORED_TXID); + v["info"]["owner"]["index"] = 1.into(); + v +} + +async fn restore( + app: &App, +) -> Result { + shakedex_restore_lock(app.state(), NAME.into()).await +} + +/// R32: the owner coin `getnameinfo` names, read with `GET /coin`, is a +/// FINALIZE at the lock address of the key derived from the seed for this +/// name, at the name's height: adopted as a Restored lock keyed by name and +/// that outpoint, with the derived public key and no listing details; a +/// second restore is refused, and the export says to import its file. +#[tokio::test] +async fn restore_lock_by_name_adopts_a_lock_at_the_derived_address() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + let s = restore(&app).await.expect("restored"); + assert_eq!(s.state, ListingState::Restored); + assert_eq!( + (s.lock_txid.as_deref(), s.lock_vout), + (Some(RESTORED_TXID), Some(1)) + ); + assert_eq!( + ( + s.payment_address.as_ref(), + s.steps.len(), + s.finalize_draft_id.as_ref() + ), + (None, 0, None) + ); + let l = open_listing(&app).expect("the open listing"); + assert_eq!(l.id, s.id); + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + assert_eq!(l.lock_pubkey_hex, hex::encode(key.pubkey)); + assert_eq!( + (l.lock_transfer_txid, l.lock_transfer_draft_id), + (None, None) + ); + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("already tracked"), "{e}"); + let e = with_db(&app, |c| { + crate::commands::shakedex::export_listing_file_from_conn(c, PROFILE, &s.id).unwrap_err() + }); + assert!(err_text(e).contains("import its saved file")); + assert_eq!( + count(&app, "wallet_tx_drafts"), + 0, + "a restore writes no draft" + ); + assert_eq!(count(&app, "shakedex_listings"), 1); +} + +/// Nothing is adopted when the owner coin is not in our lock: at our own +/// address, at the lock address of another key, a coin at our lock that is +/// neither FINALIZE nor TRANSFER, or hsd's null owner (a name without one, +/// no coin read); nothing is written. +#[tokio::test] +async fn restore_lock_refuses_a_name_not_at_our_lock() { + let other_lock = derive_lock_key(&master(), Network::Regtest, 0, "othername") + .unwrap() + .address; + for (case, address, cov) in [ + ("our address", addr00(Network::Regtest).0, COV_FINALIZE), + ("another lock", other_lock, COV_FINALIZE), + ( + "an UPDATE at our lock", + lock_address(Network::Regtest), + COV_UPDATE, + ), + ] { + let coin = restored_coin(&address, cov, NAME_HEIGHT, None); + let (_node, _m, app) = restore_fixture(restored_info(), Some(coin)).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("not in this wallet's lock"), "{case}: {e}"); + assert_eq!(count(&app, "shakedex_listings"), 0, "{case}"); + } + let mut info = restored_info(); + info["info"]["owner"] = json!({ "hash": "00".repeat(32), "index": u32::MAX }); + let (_node, mocks, app) = restore_fixture(info, Some(our_lock_coin())).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("not in this wallet's lock"), "null owner: {e}"); + assert!(!mocks[2].matched_async().await, "null owner: no coin read"); + assert_eq!(count(&app, "shakedex_listings"), 0, "null owner"); +} + +/// A FINALIZE at our lock whose covenant commits to another name height +/// than hsd's is left over from an earlier registration: refused. +#[tokio::test] +async fn restore_lock_refuses_a_leftover_lock_coin() { + let coin = restored_coin( + &lock_address(Network::Regtest), + COV_FINALIZE, + NAME_HEIGHT - 1, + None, + ); + let (_node, _m, app) = restore_fixture(restored_info(), Some(coin)).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("left over from an earlier registration"), "{e}"); + assert_eq!(count(&app, "shakedex_listings"), 0); +} + +/// `getnameinfo` with no `info`: an expired name has no lock to restore; no +/// coin is read. +#[tokio::test] +async fn restore_lock_refuses_an_expired_name() { + let (_node, mocks, app) = restore_fixture( + json!({ "info": null, "start": null }), + Some(our_lock_coin()), + ) + .await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("expired"), "{e}"); + assert!(!mocks[2].matched_async().await, "no coin read"); + assert_eq!(count(&app, "shakedex_listings"), 0); +} + +/// A revoked name (`info.revoked` not 0) has no lock to restore, whatever +/// its owner coin; no coin is read. +#[tokio::test] +async fn restore_lock_refuses_a_revoked_name() { + let mut info = restored_info(); + info["info"]["revoked"] = 1_990.into(); + let (_node, mocks, app) = restore_fixture(info, Some(our_lock_coin())).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("revoked"), "{e}"); + assert!(!mocks[2].matched_async().await, "no coin read"); + assert_eq!(count(&app, "shakedex_listings"), 0); +} + +/// The TRANSFER toward our lock is mined but the FINALIZE into it is not: +/// the owner coin is that TRANSFER, at our own address, committing to the +/// lock. Refused, pointing at Cancel transfer. +#[tokio::test] +async fn restore_lock_refuses_a_name_still_locking() { + let program = derive_lock_key(&master(), Network::Regtest, 0, NAME) + .unwrap() + .program; + let coin = restored_coin( + &addr00(Network::Regtest).0, + COV_TRANSFER, + NAME_HEIGHT, + Some(&program), + ); + let (_node, _m, app) = restore_fixture(restored_info(), Some(coin)).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!( + e.contains("still on its way into the lock") && e.contains("Cancel transfer"), + "{e}" + ); + assert_eq!(count(&app, "shakedex_listings"), 0); +} + +/// The owner coin at our lock is a TRANSFER, not a FINALIZE: a cancel +/// awaiting its finalize, or a mined purchase. Refused, so no listing and no +/// FINALIZE exist for it (spec §5). +#[tokio::test] +async fn restore_lock_refuses_a_lock_coin_already_a_transfer() { + for (case, committed) in [("a cancel", [3u8; 20]), ("a purchase", [9u8; 20])] { + let coin = restored_coin( + &lock_address(Network::Regtest), + COV_TRANSFER, + NAME_HEIGHT, + Some(&committed), + ); + let (_node, _m, app) = restore_fixture(restored_info(), Some(coin)).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("already a TRANSFER"), "{case}: {e}"); + assert_eq!(count(&app, "shakedex_listings"), 0, "{case}"); + } +} + +/// hsd's 404 for the owner coin (spent in its mempool) is no verdict: the +/// restore is refused until that is mined, and nothing is written. +#[tokio::test] +async fn restore_lock_refuses_an_owner_coin_spent_in_the_mempool() { + let (_node, mocks, app) = restore_fixture(restored_info(), None).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("being spent in the node's mempool"), "{e}"); + assert!(mocks[2].matched_async().await, "the coin was read"); + assert_eq!(count(&app, "shakedex_listings"), 0); +} + +/// A listing of this profile that already holds the lock coin (here a +/// cancelled one, so no listing of the name is open) is refused: a lock +/// coin is tracked by one listing. +#[tokio::test] +async fn restore_lock_refuses_a_lock_coin_a_listing_already_has() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + with_db(&app, |c| { + let mut l = listing("old", NAME, ListingState::Cancelled); + l.wallet_profile_id = PROFILE.into(); + l.lock_txid = Some(RESTORED_TXID.into()); + l.lock_vout = Some(1); + queries::insert_shakedex_listing(c, &l).unwrap(); + }); + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains("lock coin is already tracked"), "{e}"); + assert_eq!(count(&app, "shakedex_listings"), 1); +} + +/// A name with an open listing of this profile (here still Locking, so it +/// holds no lock coin yet) is refused: one open listing per name. +#[tokio::test] +async fn restore_lock_refuses_a_name_with_an_open_listing() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + with_db(&app, |c| { + let mut l = listing("open", NAME, ListingState::Locking); + l.wallet_profile_id = PROFILE.into(); + queries::insert_shakedex_listing(c, &l).unwrap(); + }); + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains(&format!("'{NAME}' is already tracked")), "{e}"); + assert_eq!(count(&app, "shakedex_listings"), 1); +} + +/// Every field of hsd's replies the restore reads fails closed: a reply +/// without it, or one that disagrees with what was asked, is "could not +/// check" (`AppError::Rpc`), and nothing is written. +#[tokio::test] +async fn restore_lock_with_a_node_reply_missing_a_field_writes_nothing() { + let without_info = |field: &str| { + let mut v = restored_info(); + v["info"].as_object_mut().unwrap().remove(field); + v + }; + let without_coin = |field: &str| { + let mut v = our_lock_coin(); + v.as_object_mut().unwrap().remove(field); + v + }; + let coin_with = |key: &str, value: Value| { + let mut v = our_lock_coin(); + v[key] = value; + v + }; + let mut other_name = our_lock_coin(); + other_name["covenant"]["items"][0] = + hex::encode(crate::noncustodial::names::hash_name("othername").unwrap()).into(); + let mut no_height_item = our_lock_coin(); + no_height_item["covenant"]["items"][1] = "zz".into(); + let mut info_null_owner = restored_info(); + info_null_owner["info"]["owner"] = Value::Null; + let cases: Vec<(&str, Value, Value)> = vec![ + ("no info", json!({ "start": null }), our_lock_coin()), + ("no owner", without_info("owner"), our_lock_coin()), + ("null owner", info_null_owner, our_lock_coin()), + ("no height", without_info("height"), our_lock_coin()), + ("no revoked", without_info("revoked"), our_lock_coin()), + ( + "coin without address", + restored_info(), + without_coin("address"), + ), + ( + "coin without covenant", + restored_info(), + without_coin("covenant"), + ), + ( + "coin without height", + restored_info(), + without_coin("height"), + ), + ( + "coin in the mempool", + restored_info(), + coin_with("height", (-1).into()), + ), + ( + "coin of another txid", + restored_info(), + coin_with("hash", "66".repeat(32).into()), + ), + ( + "coin of another index", + restored_info(), + coin_with("index", 0.into()), + ), + ("coin of another name", restored_info(), other_name), + ("unreadable name height", restored_info(), no_height_item), + ]; + for (case, info, coin) in cases { + let (_node, _m, app) = restore_fixture(info, Some(coin)).await; + let e = restore(&app).await.unwrap_err(); + assert!( + matches!(e, crate::error::AppError::Rpc(_)), + "{case}: {}", + err_text(e) + ); + assert_eq!(count(&app, "shakedex_listings"), 0, "{case}"); + } +} + +/// R32: the recovery phrase is needed (a hardened key cannot come from the +/// xpub): a locked wallet is refused before the node is read. +#[tokio::test] +async fn restore_lock_needs_the_unlocked_signer() { + let (_node, mocks, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + *app.state::().signer.lock().unwrap() = None; + assert!(matches!( + restore(&app).await.unwrap_err(), + crate::error::AppError::WalletLocked + )); + assert!(!mocks[1].matched_async().await, "the name was not read"); + assert_eq!(count(&app, "shakedex_listings"), 0); +} + +/// R16/R32: Ledger, watch-only and extended-private-key profiles cannot +/// derive the lock key (no seed for the hardened path): refused with the +/// sentence the UI shows, before the node is read. +#[tokio::test] +async fn restore_lock_refused_for_ledger_and_watch_only() { + let sentence = crate::noncustodial::shakedex::RECOVERY_PHRASE_ONLY; + for kind in ["ledger_hardware", "xpriv_hot", "watch_only_xpub"] { + let (_node, mocks, app) = + restore_fixture_of(kind, restored_info(), Some(our_lock_coin())).await; + let e = err_text(restore(&app).await.unwrap_err()); + assert!(e.contains(sentence), "{kind}: {e}"); + assert!( + !mocks[1].matched_async().await, + "{kind}: the name was not read" + ); + assert_eq!(count(&app, "shakedex_listings"), 0, "{kind}"); + } +} + +/// R16/R29: a restore needs a node that can send (its only action on a +/// Restored lock, Cancel, sends), with the sentence the UI shows; the name is +/// not read. +#[tokio::test] +async fn restore_lock_refused_without_write_capability() { + let (_node, mocks, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + with_db(&app, |c| set(c, "chain_source", "explorer")); + let e = err_text(restore(&app).await.unwrap_err()); + assert!( + e.contains(crate::commands::shakedex::NEEDS_SENDING_NODE), + "{e}" + ); + assert!(!mocks[1].matched_async().await, "the name was not read"); + assert_eq!(count(&app, "shakedex_listings"), 0); +} + +// --- Upgrade a Restored lock from its own listing file (T4, R32) ------------ + +use crate::commands::shakedex::shakedex_import_own_listing_file; +use crate::noncustodial::shakedex::listing_file::{ + write_listing_file, NewListingFile, PriceStep, MAX_LISTING_FILE_BYTES, +}; + +/// A listing file over `(RESTORED_TXID, vout)` with `pubkey`, one step at +/// 5 HNS signed by `signer` (a lock key) over that coin's template. +fn own_file( + vout: u32, + pubkey: [u8; 33], + signer: &crate::noncustodial::shakedex::lock_key::LockKey, + payment: &str, +) -> String { + let mut lock_txid = [0u8; 32]; + hex::decode_to_slice(RESTORED_TXID, &mut lock_txid).unwrap(); + let signature = sell::sign_step( + signer, + &StepTemplate { + lock_outpoint: (lock_txid, vout), + lock_value: NAME_VALUE, + lock_pubkey: &signer.pubkey, + payment: crate::noncustodial::tx::output_address_from_string(Network::Regtest, payment) + .unwrap(), + price: 5_000_000, + lock_time_secs: 1_700_000_000, + }, + ) + .unwrap(); + write_listing_file( + &NewListingFile { + name: NAME, + lock_txid, + lock_vout: vout, + public_key: pubkey, + payment_addr: payment, + steps: &[PriceStep { + price: 5_000_000, + lock_time: 1_700_000_000, + signature, + fee: 0, + }], + expires_at: 1_731_536_000, + }, + Network::Regtest, + ) + .unwrap() +} + +/// Our own file for the restored lock: the derived key over `(RESTORED_TXID, +/// 1)`, paying our address 0/0. +fn good_file() -> String { + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + own_file(1, key.pubkey, &key, &addr00(Network::Regtest).0) +} + +async fn import( + app: &App, + file: String, +) -> Result { + shakedex_import_own_listing_file(app.state(), file).await +} + +/// The listing is still the Restored lock as the restore wrote it: no +/// payment address, steps or file. +fn assert_still_restored(app: &App, case: &str) { + let l = open_listing(app).expect("the open listing"); + assert_eq!(l.state, ListingState::Restored, "{case}"); + assert_eq!( + ( + l.payment_address.as_deref(), + l.steps_json.as_str(), + l.listing_file_json.as_deref(), + l.expires_at + ), + (None, "[]", None, None), + "{case}" + ); +} + +/// hsd answers `coin` (`None`: its 404) for `(RESTORED_TXID, 1)` from now on. +async fn answer_coin(node: &mut ServerGuard, mocks: &mut Vec, coin: Option) { + mocks.remove(2).remove_async().await; + let path = format!("/coin/{RESTORED_TXID}/1"); + let m = node.mock("GET", path.as_str()); + let m = match coin { + Some(c) => m + .with_header("content-type", "application/json") + .with_body(c.to_string()), + None => m.with_status(404), + }; + mocks.insert(2, m.create_async().await); +} + +/// R32: a Restored lock and its own file — same lock outpoint, same public +/// key, every step signed by the lock over the lock coin — make a full +/// listing: Listed, with the file's payment address, steps, expiry and mode, +/// and the file exportable again. +#[tokio::test] +async fn own_listing_file_upgrades_a_restored_lock() { + let coin = restored_coin( + &lock_address(Network::Regtest), + COV_FINALIZE, + NAME_HEIGHT, + None, + ); + let (mut node, mut mocks, app) = restore_fixture(restored_info(), Some(coin.clone())).await; + let r = restore(&app).await.expect("restored"); + answer_coin(&mut node, &mut mocks, Some(coin)).await; + let pay = addr00(Network::Regtest).0; + let file = good_file(); + let s = import(&app, file.clone()).await.expect("imported"); + assert!(mocks[2].matched_async().await, "the lock coin was read"); + assert_eq!(s.id, r.id); + assert_eq!(s.state, ListingState::Listed); + assert_eq!(s.mode, ListingMode::BuyNow); + assert_eq!(s.payment_address.as_deref(), Some(pay.as_str())); + assert_eq!((s.steps.len(), s.steps[0].price), (1, 5_000_000)); + assert_eq!(s.expires_at, Some(1_731_536_000)); + let exported = with_db(&app, |c| { + export_listing_file_from_conn(c, PROFILE, &s.id).unwrap() + }); + let (a, b) = ( + ListingFile::parse(&exported, Network::Regtest).unwrap(), + ListingFile::parse(&file, Network::Regtest).unwrap(), + ); + assert_eq!( + (a.lock_txid, a.lock_vout, a.public_key, a.payment_addr), + (b.lock_txid, b.lock_vout, b.public_key, b.payment_addr) + ); + assert_eq!(a.steps, b.steps); + // Once Listed it is no longer a Restored lock: a second import is refused. + let e = err_text(import(&app, file).await.unwrap_err()); + assert!(e.contains("no Restored lock"), "{e}"); + assert_eq!(count(&app, "shakedex_listings"), 1); +} + +/// R32: a file for another key, or for another lock coin, is refused, and +/// the Restored lock stays as it was. +#[tokio::test] +async fn own_listing_file_for_another_key_is_refused() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + let other = derive_lock_key(&master(), Network::Regtest, 0, "othername").unwrap(); + let pay = addr00(Network::Regtest).0; + for (case, file, needle) in [ + ( + "another key", + own_file(1, other.pubkey, &other, &pay), + "another lock key", + ), + ( + "another lock coin", + own_file(2, key.pubkey, &key, &pay), + "another lock coin", + ), + ] { + let e = err_text(import(&app, file).await.unwrap_err()); + assert!(e.contains(needle), "{case}: {e}"); + assert_still_restored(&app, case); + } +} + +/// Deviation 6: while the lock coin is a coin, every step of the file must +/// be signed by the lock over it (at the coin's value as hsd reports it); a +/// step signed by another key, or over another value, is refused. +#[tokio::test] +async fn own_listing_file_with_a_step_not_signed_by_the_lock_is_refused() { + let (mut node, mut mocks, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + let other = derive_lock_key(&master(), Network::Regtest, 0, "othername").unwrap(); + let file = own_file(1, key.pubkey, &other, &addr00(Network::Regtest).0); + let e = err_text(import(&app, file).await.unwrap_err()); + assert!(e.contains("not signed by this lock"), "another signer: {e}"); + assert_still_restored(&app, "another signer"); + // The lock signed over NAME_VALUE; hsd says the coin holds one doo more. + let mut coin = our_lock_coin(); + coin["value"] = (NAME_VALUE + 1).into(); + answer_coin(&mut node, &mut mocks, Some(coin)).await; + let e = err_text(import(&app, good_file()).await.unwrap_err()); + assert!(e.contains("not signed by this lock"), "another value: {e}"); + assert_still_restored(&app, "another value"); +} + +/// Deviation 6: once the lock coin is spent (hsd's 404), the file's lock +/// outpoint and key are all that is checked: its steps are not verified, and +/// the chain judges the sale (R22) by the payment address it supplies. +#[tokio::test] +async fn own_listing_file_for_a_spent_lock_coin_checks_only_the_outpoint_and_key() { + let (mut node, mut mocks, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + answer_coin(&mut node, &mut mocks, None).await; + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + let other = derive_lock_key(&master(), Network::Regtest, 0, "othername").unwrap(); + let pay = addr00(Network::Regtest).0; + let s = import(&app, own_file(1, key.pubkey, &other, &pay)) + .await + .expect("imported"); + assert!(mocks[2].matched_async().await, "the lock coin was read"); + assert_eq!(s.state, ListingState::Listed); + assert_eq!(s.payment_address.as_deref(), Some(pay.as_str())); +} + +/// R32: the file's public key must be the lock key this wallet derives from +/// its seed for the name, account and network, not only the key the +/// Restored row holds; a row with a key the seed does not give is refused. +#[tokio::test] +async fn own_listing_file_with_a_key_this_wallet_does_not_derive_is_refused() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + let other = derive_lock_key(&master(), Network::Regtest, 0, "othername").unwrap(); + with_db(&app, |c| { + let mut l = listing("restored", NAME, ListingState::Restored); + l.wallet_profile_id = PROFILE.into(); + l.lock_pubkey_hex = hex::encode(other.pubkey); + l.lock_txid = Some(RESTORED_TXID.into()); + l.lock_vout = Some(1); + l.payment_address = None; + queries::insert_shakedex_listing(c, &l).unwrap(); + }); + let file = own_file(1, other.pubkey, &other, &addr00(Network::Regtest).0); + let e = err_text(import(&app, file).await.unwrap_err()); + assert!(e.contains("not the lock key this wallet derives"), "{e}"); + assert_still_restored(&app, "a key not derived"); +} + +/// R32, Step 1: the file's payment address must be one of this profile's +/// derived addresses on this network: an address of our seed the wallet has +/// not derived, or one of another network, is refused. +#[tokio::test] +async fn own_listing_file_paying_an_address_not_ours_is_refused() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + let (_sk, _pk, underived) = hd::derive_address(Network::Regtest, &seed(), 0, 0, 7).unwrap(); + let e = err_text( + import(&app, own_file(1, key.pubkey, &key, &underived)) + .await + .unwrap_err(), + ); + assert!(e.contains("not one of this wallet's addresses"), "{e}"); + assert_still_restored(&app, "underived"); + // The same file paying our address 0/0 as mainnet spells it. + let mut v: Value = serde_json::from_str(&good_file()).unwrap(); + v["paymentAddr"] = addr00(Network::Main).0.into(); + let e = err_text(import(&app, v.to_string()).await.unwrap_err()); + assert!(e.contains("this listing is for mainnet"), "{e}"); + assert_still_restored(&app, "mainnet"); +} + +/// We never write a market fee (R23): a file of ours with one is not ours, +/// even though the seller's signature does not cover the fee. +#[tokio::test] +async fn own_listing_file_with_a_market_fee_is_refused() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + let mut v: Value = serde_json::from_str(&good_file()).unwrap(); + v["feeAddr"] = addr00(Network::Regtest).0.into(); + // A fee address beside zero fees is no fee to the parser, but this + // wallet writes `feeAddr: null`: not its own file. + let e = err_text(import(&app, v.to_string()).await.unwrap_err()); + assert!(e.contains("market fee"), "fee address alone: {e}"); + assert_still_restored(&app, "fee address"); + v["data"][0]["fee"] = 100_000.into(); + let e = err_text(import(&app, v.to_string()).await.unwrap_err()); + assert!(e.contains("market fee"), "{e}"); + assert_still_restored(&app, "fee"); +} + +/// The file is foreign input read by the strict parser, size limit first: +/// an oversized file (here our own file padded with whitespace) is refused. +#[tokio::test] +async fn own_listing_file_over_the_size_limit_is_refused() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + let file = format!("{}{}", good_file(), " ".repeat(MAX_LISTING_FILE_BYTES)); + let e = err_text(import(&app, file).await.unwrap_err()); + assert!(e.contains("larger than"), "{e}"); + assert_still_restored(&app, "size"); +} + +/// Only a Restored lock is upgraded: no listing of the name, or a listing in +/// another state (here Locking), is refused. +#[tokio::test] +async fn own_listing_file_without_a_restored_lock_is_refused() { + let (_node, _m, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + let e = err_text(import(&app, good_file()).await.unwrap_err()); + assert!(e.contains("no Restored lock"), "no listing: {e}"); + assert_eq!(count(&app, "shakedex_listings"), 0); + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + with_db(&app, |c| { + let mut l = listing("locking", NAME, ListingState::Locking); + l.wallet_profile_id = PROFILE.into(); + l.lock_pubkey_hex = hex::encode(key.pubkey); + l.lock_txid = Some(RESTORED_TXID.into()); + l.lock_vout = Some(1); + l.payment_address = None; + queries::insert_shakedex_listing(c, &l).unwrap(); + }); + let e = err_text(import(&app, good_file()).await.unwrap_err()); + assert!(e.contains("no Restored lock"), "Locking: {e}"); + let l = open_listing(&app).unwrap(); + assert_eq!( + (l.state, l.payment_address, l.listing_file_json), + (ListingState::Locking, None, None) + ); +} + +/// Every field of hsd's lock coin reply the import reads fails closed: a +/// coin of another outpoint, without its address or at another address, or +/// with a value no coin has, is "could not check" (`AppError::Rpc`), and +/// nothing is written. +#[tokio::test] +async fn own_listing_file_with_a_node_reply_missing_a_field_writes_nothing() { + let (mut node, mut mocks, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + let coin_with = |key: &str, value: Option| { + let mut v = our_lock_coin(); + match value { + Some(x) => v[key] = x, + None => { + v.as_object_mut().unwrap().remove(key); + } + } + v + }; + let cases = [ + ( + "another txid", + coin_with("hash", Some("66".repeat(32).into())), + ), + ("another index", coin_with("index", Some(0.into()))), + ("no address", coin_with("address", None)), + ( + "another address", + coin_with("address", Some(addr00(Network::Regtest).0.into())), + ), + ("a negative value", coin_with("value", Some((-1).into()))), + ("no value", coin_with("value", None)), + ]; + for (case, coin) in cases { + answer_coin(&mut node, &mut mocks, Some(coin)).await; + let e = import(&app, good_file()).await.unwrap_err(); + assert!( + matches!(e, crate::error::AppError::Rpc(_)), + "{case}: {}", + err_text(e) + ); + assert_still_restored(&app, case); + } +} + +/// R32: the lock key is re-derived from the seed, so the unlocked signer is +/// needed: a locked wallet is refused before the lock coin is read, and +/// nothing is written. +#[tokio::test] +async fn own_listing_file_needs_the_unlocked_signer() { + let (mut node, mut mocks, app) = restore_fixture(restored_info(), Some(our_lock_coin())).await; + restore(&app).await.expect("restored"); + answer_coin(&mut node, &mut mocks, Some(our_lock_coin())).await; + *app.state::().signer.lock().unwrap() = None; + assert!(matches!( + import(&app, good_file()).await.unwrap_err(), + crate::error::AppError::WalletLocked + )); + assert!( + !mocks[2].matched_async().await, + "the lock coin was not read" + ); + assert_still_restored(&app, "locked"); +} + +/// R16/R32: Ledger, watch-only and extended-private-key profiles cannot +/// derive the lock key: refused with the sentence the UI shows, before the +/// lock coin is read, and nothing is written. +#[tokio::test] +async fn own_listing_file_refused_for_ledger_and_watch_only() { + let sentence = crate::noncustodial::shakedex::RECOVERY_PHRASE_ONLY; + let key = derive_lock_key(&master(), Network::Regtest, 0, NAME).unwrap(); + for kind in ["ledger_hardware", "xpriv_hot", "watch_only_xpub"] { + let (_node, mocks, app) = + restore_fixture_of(kind, restored_info(), Some(our_lock_coin())).await; + with_db(&app, |c| { + let mut l = listing("restored", NAME, ListingState::Restored); + l.wallet_profile_id = PROFILE.into(); + l.lock_pubkey_hex = hex::encode(key.pubkey); + l.lock_txid = Some(RESTORED_TXID.into()); + l.lock_vout = Some(1); + l.payment_address = None; + queries::insert_shakedex_listing(c, &l).unwrap(); + }); + let e = err_text(import(&app, good_file()).await.unwrap_err()); + assert!(e.contains(sentence), "{kind}: {e}"); + assert!( + !mocks[2].matched_async().await, + "{kind}: the lock coin was not read" + ); + assert_still_restored(&app, kind); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 02b4a47..b6038ab 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -127,6 +127,12 @@ export interface HsdName { * in the transfer lockup, null until known. */ shakedex?: ShakedexNameState; + /** + * Present on a name with a listing of ours (`db::queries::ListingNameState`): + * open, or sold within the re-check window. `blocksUntilFinalize` is the + * lockup left while Locking, null otherwise or until known. + */ + listing?: ShakedexNameListing; } export interface ShakedexNameState { @@ -903,10 +909,20 @@ export type ShakedexListingState = | "restored" | "expired"; +/** Rust's `ListingMode`, camelCase. */ +export type ShakedexListingMode = "buyNow" | "reverseAuction"; + +/** The `listing` object on an Owned Names row (`db::queries::ListingNameState`). */ +export interface ShakedexNameListing { + listingId: string; + state: ShakedexListingState; + blocksUntilFinalize: number | null; +} + export interface ShakedexListingSummary { id: string; name: string; - mode: "buyNow" | "reverseAuction"; + mode: ShakedexListingMode; /** Rust's `ListingState`, in camelCase. */ state: ShakedexListingState; lockTxid: string | null;