From 6f9a4967b72acaef423ab6748fadd1908967b044 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 14:54:21 +0200 Subject: [PATCH 01/17] feat(shakedex): parse a seller's price and list what Finalize & sign makes permanent --- src-tauri/src/noncustodial/shakedex/sell.rs | 240 +++++++++++++++++++- 1 file changed, 236 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/noncustodial/shakedex/sell.rs b/src-tauri/src/noncustodial/shakedex/sell.rs index a123862..b3e504a 100644 --- a/src-tauri/src/noncustodial/shakedex/sell.rs +++ b/src-tauri/src/noncustodial/shakedex/sell.rs @@ -7,12 +7,16 @@ use crate::noncustodial::actions::PlanResult; use crate::noncustodial::covenants; use crate::noncustodial::names; use crate::noncustodial::network::{NameParams, Network}; -use crate::noncustodial::send::SpendableCoin; +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::{verify_step_signature, StepTemplate}; +use crate::noncustodial::shakedex::template::{ + secs_until_valid, valid_from_mtp, verify_step_signature, StepTemplate, +}; use crate::noncustodial::tx::{Covenant, OutputAddress}; +use crate::noncustodial::types::doos_to_hns_string; /// One lock-time unit: hsd encodes a time lock in 512-second steps. const LOCK_TIME_UNIT_SECS: u64 = 512; @@ -99,8 +103,7 @@ pub fn near_expiry_warning(blocks_left: i64) -> String { ) } -/// R31's verdict on locking a name now, or (T3) on finalizing it into the -/// lock. +/// R31's verdict on locking a name now, or on finalizing it into the lock. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExpiryNotice { Ok, @@ -247,6 +250,134 @@ pub fn build_lock_finalize_plan(i: &LockFinalizeInput) -> Result Result { + let bad = |why: String| AppError::InvalidInput(format!("price {text:?}: {why}")); + let t = text.trim(); + if t.is_empty() { + return Err(bad("enter a price in HNS".into())); + } + let (whole, frac) = t.split_once('.').unwrap_or((t, "")); + let digits = |s: &str| s.chars().all(|c| c.is_ascii_digit()); + if (whole.is_empty() && frac.is_empty()) || !digits(whole) || !digits(frac) { + return Err(bad("not a number of HNS".into())); + } + if frac.len() > 6 { + return Err(bad("HNS has at most 6 decimals".into())); + } + let supply = || bad("above the money supply".into()); + let whole: u64 = if whole.is_empty() { + 0 + } else { + whole.parse().map_err(|_| supply())? + }; + // `frac` is 0..=6 ASCII digits, so the padded string always parses. + let frac: u64 = format!("{frac:0<6}").parse().map_err(|_| supply())?; + let doos = whole + .checked_mul(1_000_000) + .and_then(|w| w.checked_add(frac)) + .ok_or_else(supply)?; + if doos == 0 { + return Err(bad("a price must be above 0".into())); + } + if doos < DUST_THRESHOLD { + return Err(bad(format!( + "below the dust limit of {}", + doos_to_hns_string(DUST_THRESHOLD) + ))); + } + if doos > MAX_MONEY { + return Err(supply()); + } + Ok(doos) +} + +/// One signed price step as `shakedex_listings.steps_json` stores it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredStep { + pub price: u64, + pub lock_time: u64, + /// 65 bytes, hex: the low-S signature and the 0x84 sighash byte. + pub signature: String, +} + +/// What the Finalize & sign confirmation (R20) is built from. +pub struct FinalizeAndSignRows<'a> { + pub name: &'a str, + pub finalize_fee: u64, + pub lock_address: &'a str, + pub payment_address: &'a str, + /// `(price, lock time)` of every step about to be signed. + pub steps: &'a [(u64, u64)], + /// The MTP the steps' validity is judged at (R3). + pub mtp: u64, +} + +/// R20: the rows of the Finalize & sign confirmation, `{ "rows": [...] }` +/// as the secure window renders a `confirm` request. +pub fn finalize_and_sign_rows(r: &FinalizeAndSignRows) -> serde_json::Value { + let row = |label: String, value: String| serde_json::json!({ "label": label, "value": value }); + let mut rows = vec![ + row( + "Action".into(), + "Finalize into the lock and sign the price".into(), + ), + row("Name".into(), r.name.into()), + row( + "Network fee (finalize into the lock)".into(), + doos_to_hns_string(r.finalize_fee), + ), + ]; + for (i, &(price, lock_time)) in r.steps.iter().enumerate() { + let when = if secs_until_valid(lock_time, r.mtp) == 0 { + "valid at once".to_string() + } else { + match i64::try_from(valid_from_mtp(lock_time)) + .ok() + .and_then(|s| chrono::DateTime::from_timestamp(s, 0)) + { + Some(t) => format!("valid from {}", t.format("%Y-%m-%d %H:%M UTC")), + // A lock time past what a date holds: say so rather than guess. + None => "valid from a time too far away to show".into(), + } + }; + rows.push(row( + format!("Price step {}", i + 1), + format!("{}, {when}", doos_to_hns_string(price)), + )); + } + rows.push(row("Paid to".into(), r.payment_address.into())); + rows.push(row("Lock address".into(), r.lock_address.into())); + rows.push(row("Warning".into(), STEP_SIGNATURE_PERMANENCE.into())); + serde_json::json!({ "rows": rows }) +} + +/// R31's refusal at Finalize & sign: the name would expire within a day of +/// its FINALIZE into the lock. Renewing needs the TRANSFER undone first. +pub fn expires_before_finalize(name: &str, expiry_end: i64) -> AppError { + AppError::InvalidInput(format!( + "'{name}' expires at block {expiry_end}, too soon to finalize it into the lock: \ + cancel the transfer (Cancel transfer) and renew the name first" + )) +} + #[cfg(test)] mod tests { use super::*; @@ -410,4 +541,105 @@ mod tests { let out: u64 = res.plan.outputs.iter().map(|o| o.value).sum(); assert_eq!(res.input_total, out + res.fee); } + + /// R19: a price is HNS with at most 6 decimals, at least the dust limit + /// and at most the money supply; every refusal says why. + #[test] + fn step_price_boundaries() { + use crate::noncustodial::send::DUST_THRESHOLD; + use crate::noncustodial::shakedex::purchase::MAX_MONEY; + assert_eq!(parse_step_price("5").unwrap(), 5_000_000); + assert_eq!(parse_step_price(" 1.5 ").unwrap(), 1_500_000); + assert_eq!(parse_step_price(".5").unwrap(), 500_000); + assert_eq!(parse_step_price("1.").unwrap(), 1_000_000); + assert_eq!(parse_step_price("1.000000").unwrap(), 1_000_000); + assert_eq!(parse_step_price("0.001").unwrap(), DUST_THRESHOLD); + assert!(parse_step_price("0.000001") + .unwrap_err() + .to_string() + .contains("dust")); + assert!(parse_step_price("0.000999") + .unwrap_err() + .to_string() + .contains("dust")); + assert_eq!(parse_step_price("2040000000").unwrap(), MAX_MONEY); + for (text, why) in [ + ("0", "above 0"), + ("0.000000", "above 0"), + ("2040000000.000001", "money supply"), + ("99999999999999999999", "money supply"), + ("1.0000001", "6 decimals"), + ("", "price in HNS"), + (" ", "price in HNS"), + (".", "not a number"), + ("-1", "not a number"), + ("+1", "not a number"), + ("1e3", "not a number"), + ("1,5", "not a number"), + ("1.2.3", "not a number"), + ("abc", "not a number"), + // Not ASCII digits, though `char::is_numeric` takes them. + ("\u{661}", "not a number"), + ("1\u{b2}", "not a number"), + ] { + let e = parse_step_price(text).unwrap_err(); + assert!(matches!(e, AppError::InvalidInput(_)), "{text:?}: {e:?}"); + assert!(e.to_string().contains(why), "{text:?}: {e}"); + } + } + + /// R20: the fee of the FINALIZE, every step's price and when it becomes + /// valid, where the money goes, and the permanence of each signature. + #[test] + fn finalize_and_sign_rows_list_fee_steps_and_permanence() { + let mtp = 1_783_696_480; + let rows = finalize_and_sign_rows(&FinalizeAndSignRows { + name: "dexsale", + finalize_fee: 12_345, + lock_address: "rs1qlock", + payment_address: "rs1qpay", + steps: &[ + (5_000_000, buy_now_lock_time(mtp)), + (4_000_000, mtp + 3_600), + ], + mtp, + }); + let rows = rows["rows"].as_array().unwrap(); + let get = |label: &str| -> Vec { + rows.iter() + .filter(|r| r["label"] == label) + .map(|r| r["value"].as_str().unwrap().to_string()) + .collect() + }; + assert_eq!(get("Name"), ["dexsale"]); + assert_eq!( + get("Network fee (finalize into the lock)"), + ["0.012345 HNS"] + ); + assert_eq!(get("Price step 1"), ["5.000000 HNS, valid at once"]); + let later = &get("Price step 2")[0]; + assert!(later.starts_with("4.000000 HNS, valid from "), "{later}"); + assert!(later.ends_with(" UTC"), "{later}"); + assert_eq!(get("Paid to"), ["rs1qpay"]); + assert_eq!(get("Lock address"), ["rs1qlock"]); + assert_eq!(get("Warning"), [STEP_SIGNATURE_PERMANENCE]); + assert!(STEP_SIGNATURE_PERMANENCE.contains("anyone")); + assert!(STEP_SIGNATURE_PERMANENCE.contains("cancel is mined")); + } + + /// The stored step reads back as written (`steps_json`, read by T5/T6/T8). + #[test] + fn stored_step_round_trips_in_camel_case() { + let s = StoredStep { + price: 5, + lock_time: 7, + signature: "ab".into(), + }; + let j = serde_json::to_value(&s).unwrap(); + assert_eq!( + j, + serde_json::json!({ "price": 5, "lockTime": 7, "signature": "ab" }) + ); + assert_eq!(serde_json::from_value::(j).unwrap(), s); + } } From b47a36532b1b71748f2ea0797c676cc5325a525c Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 15:01:21 +0200 Subject: [PATCH 02/17] feat(shakedex): write our listing file and read it back strictly --- .../src/noncustodial/shakedex/listing_file.rs | 44 +++++++ .../src/tests/shakedex_listing_file_tests.rs | 124 ++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/src-tauri/src/noncustodial/shakedex/listing_file.rs b/src-tauri/src/noncustodial/shakedex/listing_file.rs index b2335da..b5183a8 100644 --- a/src-tauri/src/noncustodial/shakedex/listing_file.rs +++ b/src-tauri/src/noncustodial/shakedex/listing_file.rs @@ -290,3 +290,47 @@ impl ListingFile { } } } + +/// What [`write_listing_file`] writes: one of our own listings. +pub struct NewListingFile<'a> { + pub name: &'a str, + pub lock_txid: [u8; 32], + pub lock_vout: u32, + pub public_key: [u8; 33], + pub payment_addr: &'a str, + pub steps: &'a [PriceStep], + /// Unix seconds (R23: the MTP at signing plus 365 days). + pub expires_at: u64, +} + +/// R23: our listing as Shakedex v2 JSON, with the keys the CLI writes and +/// LearnHNS serves, no market fee (`feeAddr: null`, every `fee` 0 unless a +/// step carries one) and `expiresAt` in seconds. The file is read back by +/// [`ListingFile::parse`] before it is returned: a file our own strict +/// reader refuses is never handed out. +pub fn write_listing_file(l: &NewListingFile, network: Network) -> Result { + let file = serde_json::json!({ + "data": l.steps.iter().map(|s| serde_json::json!({ + "fee": s.fee, + "lockTime": s.lock_time, + "price": s.price, + "signature": hex::encode(s.signature), + })).collect::>(), + "expiresAt": l.expires_at, + "feeAddr": Value::Null, + "lockingOutputIdx": l.lock_vout, + "lockingTxHash": hex::encode(l.lock_txid), + "name": l.name, + "paymentAddr": l.payment_addr, + "publicKey": hex::encode(l.public_key), + "version": VERSION, + }); + let json = serde_json::to_string(&file)?; + let back = ListingFile::parse(&json, network)?; + if serde_json::from_str::(&back.to_json()?)? != file { + return Err(AppError::Other( + "the listing file did not read back as written".into(), + )); + } + Ok(json) +} diff --git a/src-tauri/src/tests/shakedex_listing_file_tests.rs b/src-tauri/src/tests/shakedex_listing_file_tests.rs index 27c511b..ad6f692 100644 --- a/src-tauri/src/tests/shakedex_listing_file_tests.rs +++ b/src-tauri/src/tests/shakedex_listing_file_tests.rs @@ -328,3 +328,127 @@ fn unreadable_expiry_refused() { ); } } + +use crate::noncustodial::hd::ExtendedPrivKey; +use crate::noncustodial::rpc::{BlockchainInfo, NodeCoin}; +use crate::noncustodial::shakedex::listing_file::{write_listing_file, NewListingFile, PriceStep}; +use crate::noncustodial::shakedex::lock_key::derive_lock_key; +use crate::noncustodial::shakedex::sell::{buy_now_lock_time, sign_step}; +use crate::noncustodial::shakedex::template::StepTemplate; +use crate::noncustodial::shakedex::verify::{verify_listing, Verdict}; +use crate::noncustodial::tx::output_address_from_string; +use crate::tests::mock_node_rpc::MockNodeRpc; + +const W_NAME: &str = "dexsale"; +const W_LOCK_TXID: [u8; 32] = [ + 0x6c, 0x01, 0x6c, 0x02, 0x6c, 0x03, 0x6c, 0x04, 0x6c, 0x05, 0x6c, 0x06, 0x6c, 0x07, 0x6c, 0x08, + 0x6c, 0x09, 0x6c, 0x0a, 0x6c, 0x0b, 0x6c, 0x0c, 0x6c, 0x0d, 0x6c, 0x0e, 0x6c, 0x0f, 0x6c, 0x10, +]; +const W_LOCK_VALUE: u64 = 1_000_000; +const W_MTP: u64 = 1_700_000_000; +const W_TIP: i64 = 2_000; +const W_NAME_HEIGHT: u32 = 50; + +/// A Buy Now signed by a lock key derived here, as Finalize & sign signs it. +fn written( + network: Network, +) -> ( + String, + crate::noncustodial::shakedex::lock_key::LockKey, + String, +) { + let master = ExtendedPrivKey::from_seed(&[7u8; 64]).unwrap(); + let key = derive_lock_key(&master, network, 0, W_NAME).unwrap(); + let pay = crate::noncustodial::address::encode_p2wpkh(network, &[9; 20]).unwrap(); + let lock_time = buy_now_lock_time(W_MTP); + let t = StepTemplate { + lock_outpoint: (W_LOCK_TXID, 0), + lock_value: W_LOCK_VALUE, + lock_pubkey: &key.pubkey, + payment: output_address_from_string(network, &pay).unwrap(), + price: 5_000_000, + lock_time_secs: lock_time, + }; + let sig = sign_step(&key, &t).unwrap(); + let steps = [PriceStep { + price: 5_000_000, + lock_time, + signature: sig, + fee: 0, + }]; + let json = write_listing_file( + &NewListingFile { + name: W_NAME, + lock_txid: W_LOCK_TXID, + lock_vout: 0, + public_key: key.pubkey, + payment_addr: &pay, + steps: &steps, + expires_at: W_MTP + 365 * 86_400, + }, + network, + ) + .unwrap(); + (json, key, pay) +} + +/// R23 and R2: what we write is read back by our own strict parser, field +/// for field, and comes back unchanged; R7: on a node where its lock coin is +/// our FINALIZE at the lock address and the name's owner, it is buyable. +#[tokio::test] +async fn written_listing_file_round_trips_through_the_strict_parser() { + for network in [Network::Main, Network::Regtest] { + let (json, key, pay) = written(network); + let l = ListingFile::parse(&json, network).expect("our own file parses"); + assert_eq!(l.name, W_NAME); + assert_eq!((l.lock_txid, l.lock_vout), (W_LOCK_TXID, 0)); + assert_eq!(l.public_key, key.pubkey); + assert_eq!(l.payment_addr, pay); + assert_eq!(l.fee_addr, None); + assert_eq!(l.expires_at, Some(W_MTP + 365 * 86_400)); + assert_eq!(l.steps.len(), 1); + assert_eq!(l.steps[0].signature[64], 0x84); + assert_eq!(v(&l.to_json().unwrap()), v(&json), "round-trips unchanged"); + + let txid = hex::encode(W_LOCK_TXID); + let nh = hex::encode(crate::noncustodial::names::hash_name(W_NAME).unwrap()); + let coin: NodeCoin = serde_json::from_value(serde_json::json!({ + "hash": txid, "index": 0, "value": W_LOCK_VALUE, "address": key.address, + "height": W_TIP - 5, "coinbase": false, "version": 0, + "covenant": { "type": 10, "action": "FINALIZE", "items": [ + nh, hex::encode(W_NAME_HEIGHT.to_le_bytes()), hex::encode(W_NAME), + "00", "00000000", "01000000", "00".repeat(32) ] } + })) + .unwrap(); + let name_info = serde_json::json!({ "info": { + "name": W_NAME, "height": W_NAME_HEIGHT, "renewal": W_TIP - 5, "claimed": 0, + "renewals": 1, "weak": false, "transfer": 0, "revoked": 0, + "owner": { "hash": txid, "index": 0 }, "value": W_LOCK_VALUE } }); + let mut info: BlockchainInfo = + serde_json::from_value(serde_json::json!({ "blocks": W_TIP, "headers": W_TIP })) + .unwrap(); + info.mediantime = Some(W_MTP); + let node = MockNodeRpc::new() + .with_blockchain_info(info) + .with_name_info(name_info) + .with_get_coin(move |_, _| Ok(Some(coin.clone()))); + match verify_listing(&node, network, &l).await { + Verdict::Buyable(b) => assert_eq!(b.current_step, 0, "{network:?}"), + Verdict::Hidden(h) => panic!("{network:?}: hidden {h:?}"), + } + } +} + +/// The file carries exactly the keys the CLI writes and LearnHNS serves +/// (the live dexreviews proof), so every Shakedex wallet reads it. +#[test] +fn written_listing_file_has_the_cli_field_set() { + let keys = |j: &Value| -> Vec { j.as_object().unwrap().keys().cloned().collect() }; + let (json, _, _) = written(Network::Main); + let ours = v(&json); + assert_eq!(keys(&ours), keys(&v(DEX))); + assert_eq!(keys(&ours["data"][0]), keys(&v(DEX)["data"][0])); + assert_eq!(ours["version"], 2); + assert_eq!(ours["feeAddr"], Value::Null); + assert!(ours["expiresAt"].is_u64(), "seconds, as the CLI writes it"); +} From 90b99d28a9f917e425e337dbedc72a451bc29dbd Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 15:05:52 +0200 Subject: [PATCH 03/17] test(shakedex): pin the written steps exactly and refuse a fee in our listing file --- .../src/noncustodial/shakedex/listing_file.rs | 9 +- .../src/tests/shakedex_listing_file_tests.rs | 83 +++++++++++++++++-- 2 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/noncustodial/shakedex/listing_file.rs b/src-tauri/src/noncustodial/shakedex/listing_file.rs index b5183a8..53ddf1a 100644 --- a/src-tauri/src/noncustodial/shakedex/listing_file.rs +++ b/src-tauri/src/noncustodial/shakedex/listing_file.rs @@ -304,11 +304,16 @@ pub struct NewListingFile<'a> { } /// R23: our listing as Shakedex v2 JSON, with the keys the CLI writes and -/// LearnHNS serves, no market fee (`feeAddr: null`, every `fee` 0 unless a -/// step carries one) and `expiresAt` in seconds. The file is read back by +/// LearnHNS serves, no market fee (`feeAddr: null`, every `fee` 0; a step +/// carrying one is refused) and `expiresAt` in seconds. The file is read back by /// [`ListingFile::parse`] before it is returned: a file our own strict /// reader refuses is never handed out. pub fn write_listing_file(l: &NewListingFile, network: Network) -> Result { + if l.steps.iter().any(|s| s.fee != 0) { + return Err(bad( + "we never write a market fee: every step's fee must be 0", + )); + } let file = serde_json::json!({ "data": l.steps.iter().map(|s| serde_json::json!({ "fee": s.fee, diff --git a/src-tauri/src/tests/shakedex_listing_file_tests.rs b/src-tauri/src/tests/shakedex_listing_file_tests.rs index ad6f692..bbfd77f 100644 --- a/src-tauri/src/tests/shakedex_listing_file_tests.rs +++ b/src-tauri/src/tests/shakedex_listing_file_tests.rs @@ -356,6 +356,7 @@ fn written( String, crate::noncustodial::shakedex::lock_key::LockKey, String, + PriceStep, ) { let master = ExtendedPrivKey::from_seed(&[7u8; 64]).unwrap(); let key = derive_lock_key(&master, network, 0, W_NAME).unwrap(); @@ -389,7 +390,7 @@ fn written( network, ) .unwrap(); - (json, key, pay) + (json, key, pay, steps[0].clone()) } /// R23 and R2: what we write is read back by our own strict parser, field @@ -398,7 +399,7 @@ fn written( #[tokio::test] async fn written_listing_file_round_trips_through_the_strict_parser() { for network in [Network::Main, Network::Regtest] { - let (json, key, pay) = written(network); + let (json, key, pay, step) = written(network); let l = ListingFile::parse(&json, network).expect("our own file parses"); assert_eq!(l.name, W_NAME); assert_eq!((l.lock_txid, l.lock_vout), (W_LOCK_TXID, 0)); @@ -406,9 +407,11 @@ async fn written_listing_file_round_trips_through_the_strict_parser() { assert_eq!(l.payment_addr, pay); assert_eq!(l.fee_addr, None); assert_eq!(l.expires_at, Some(W_MTP + 365 * 86_400)); - assert_eq!(l.steps.len(), 1); + // The parsed steps are the signed input, byte for byte: price, lock + // time, fee and signature. + assert_eq!(l.steps, vec![step.clone()]); + assert_eq!(l.steps[0].lock_time, buy_now_lock_time(W_MTP)); assert_eq!(l.steps[0].signature[64], 0x84); - assert_eq!(v(&l.to_json().unwrap()), v(&json), "round-trips unchanged"); let txid = hex::encode(W_LOCK_TXID); let nh = hex::encode(crate::noncustodial::names::hash_name(W_NAME).unwrap()); @@ -440,11 +443,13 @@ async fn written_listing_file_round_trips_through_the_strict_parser() { } /// The file carries exactly the keys the CLI writes and LearnHNS serves -/// (the live dexreviews proof), so every Shakedex wallet reads it. +/// (the live dexreviews proof), so every Shakedex wallet reads it. This pins +/// the key set, not their order (`serde_json` sorts keys; readers do not +/// depend on order). #[test] fn written_listing_file_has_the_cli_field_set() { let keys = |j: &Value| -> Vec { j.as_object().unwrap().keys().cloned().collect() }; - let (json, _, _) = written(Network::Main); + let (json, _, _, _) = written(Network::Main); let ours = v(&json); assert_eq!(keys(&ours), keys(&v(DEX))); assert_eq!(keys(&ours["data"][0]), keys(&v(DEX)["data"][0])); @@ -452,3 +457,69 @@ fn written_listing_file_has_the_cli_field_set() { assert_eq!(ours["feeAddr"], Value::Null); assert!(ours["expiresAt"].is_u64(), "seconds, as the CLI writes it"); } + +/// We never write a market fee: a step with one is refused, not written. +#[test] +fn a_step_with_a_fee_is_not_written() { + let (_, key, pay, mut step) = written(Network::Main); + step.fee = 1; + let steps = [step]; + let e = write_listing_file( + &NewListingFile { + name: W_NAME, + lock_txid: W_LOCK_TXID, + lock_vout: 0, + public_key: key.pubkey, + payment_addr: &pay, + steps: &steps, + expires_at: W_MTP + 365 * 86_400, + }, + Network::Main, + ) + .unwrap_err(); + assert!(e.to_string().contains("fee"), "{e}"); +} + +/// A long schedule (1000 steps, falling price, rising lock time) round-trips +/// and fits the size limit the parser enforces. +#[test] +fn a_thousand_step_schedule_round_trips_under_the_size_limit() { + let network = Network::Main; + let (_, key, pay, _) = written(network); + let base = buy_now_lock_time(W_MTP); + let steps: Vec = (0..1000u64) + .map(|i| { + let (price, lock_time) = (2_000_000_000 - i * 1_000_000, base + i * 3_600); + let t = StepTemplate { + lock_outpoint: (W_LOCK_TXID, 0), + lock_value: W_LOCK_VALUE, + lock_pubkey: &key.pubkey, + payment: output_address_from_string(network, &pay).unwrap(), + price, + lock_time_secs: lock_time, + }; + PriceStep { + price, + lock_time, + signature: sign_step(&key, &t).unwrap(), + fee: 0, + } + }) + .collect(); + let json = write_listing_file( + &NewListingFile { + name: W_NAME, + lock_txid: W_LOCK_TXID, + lock_vout: 0, + public_key: key.pubkey, + payment_addr: &pay, + steps: &steps, + expires_at: W_MTP + 365 * 86_400, + }, + network, + ) + .unwrap(); + eprintln!("1000-step listing file: {} bytes", json.len()); + assert!(json.len() <= MAX_LISTING_FILE_BYTES, "{} bytes", json.len()); + assert_eq!(ListingFile::parse(&json, network).unwrap().steps, steps); +} From 5b82d76f3a7ab05cf1dae71110d4088a1f0ddbda Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 15:12:41 +0200 Subject: [PATCH 04/17] feat(shakedex): keep the FINALIZE into the lock, its steps and its file on the listing --- src-tauri/src/commands/shakedex.rs | 1 + src-tauri/src/db/migrations.rs | 59 ++++- src-tauri/src/db/queries.rs | 197 ++++++++++++++- .../src/sql/035_shakedex_listing_finalize.sql | 4 + src-tauri/src/tests/connection_tests.rs | 4 +- src-tauri/src/tests/migration_tests.rs | 2 +- src-tauri/src/tests/shakedex_sell_tests.rs | 234 ++++++++++++++++++ 7 files changed, 488 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/sql/035_shakedex_listing_finalize.sql diff --git a/src-tauri/src/commands/shakedex.rs b/src-tauri/src/commands/shakedex.rs index 67377e9..e0bcb16 100644 --- a/src-tauri/src/commands/shakedex.rs +++ b/src-tauri/src/commands/shakedex.rs @@ -614,6 +614,7 @@ pub(crate) fn build_lock_draft_inner( state: ListingState::Locking, lock_pubkey_hex: hex::encode(i.key.pubkey), lock_transfer_draft_id: Some(draft_id.clone()), + lock_finalize_draft_id: None, lock_transfer_txid: Some(res.txid.clone()), lock_txid: None, lock_vout: None, diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index afbe790..e4e2525 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -56,6 +56,10 @@ const MIGRATIONS: &[(&str, &str)] = &[ ), ("033", include_str!("../sql/033_shakedex.sql")), ("034", include_str!("../sql/034_shakedex_listings.sql")), + ( + "035", + include_str!("../sql/035_shakedex_listing_finalize.sql"), + ), ]; pub fn run(conn: &Connection) -> Result<(), rusqlite::Error> { @@ -97,7 +101,7 @@ mod tests { let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 34, "expected 34 migrations, got {count}"); + assert_eq!(count, 35, "expected 35 migrations, got {count}"); } /// A database already at 033 (a real upgrade) takes 034 on top of its @@ -112,7 +116,7 @@ mod tests { );", ) .unwrap(); - for (version, sql) in MIGRATIONS.iter().filter(|(v, _)| *v != "034") { + for (version, sql) in MIGRATIONS.iter().filter(|(v, _)| *v < "034") { conn.execute_batch(sql).unwrap(); conn.execute( "INSERT INTO schema_version (version) VALUES (?1)", @@ -142,7 +146,54 @@ mod tests { let top: String = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(top, "034"); + assert_eq!(top, MIGRATIONS.last().unwrap().0); + } + + /// A database already at 034 (the T2 schema, with listings) takes 035 on + /// top: the new column exists and the existing listing keeps its row. + #[test] + fn migration_035_applies_on_top_of_034() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE schema_version ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ) + .unwrap(); + for (version, sql) in MIGRATIONS.iter().filter(|(v, _)| *v < "035") { + conn.execute_batch(sql).unwrap(); + conn.execute( + "INSERT INTO schema_version (version) VALUES (?1)", + [version], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO wallet_profiles (id, label, kind, network, account_xpub, account_index, watch_only) + VALUES ('p', 'P', 'mnemonic_hot', 'regtest', 'xpub', 0, 0)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO shakedex_listings (id, wallet_profile_id, name, mode, lock_pubkey_hex) + VALUES ('l1', 'p', 'dexsale', 'buy_now', '02')", + [], + ) + .unwrap(); + run(&conn).unwrap(); + let draft: Option = conn + .query_row( + "SELECT lock_finalize_draft_id FROM shakedex_listings WHERE id = 'l1'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(draft, None); + let top: String = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(top, "035"); } #[test] @@ -153,7 +204,7 @@ mod tests { let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 34); + assert_eq!(count, 35); } #[test] diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index 8464280..b8cc4c7 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1358,6 +1358,18 @@ pub fn delete_tx_draft_in_tx(tx: &rusqlite::Transaction<'_>, id: &str) -> Result params![id, ListingState::Locking], )?; } + // An unsent FINALIZE into the lock takes its steps with it: they were + // signed over a coin that never existed. A sent one (`dropped`, `failed`) + // may still land, so its listing keeps everything for the chain to judge. + if never_sent(&status) { + tx.execute( + &format!( + "UPDATE shakedex_listings SET {REVERT_TO_READY_SET} + WHERE lock_finalize_draft_id = ?1 AND state = ?2" + ), + params![id, ListingState::Finalizing], + )?; + } tx.execute( "UPDATE shakedex_purchases SET finalize_draft_id = NULL, updated_at = datetime('now') WHERE finalize_draft_id = ?1", @@ -1601,6 +1613,8 @@ pub struct ShakedexListing { pub state: ListingState, pub lock_pubkey_hex: String, pub lock_transfer_draft_id: Option, + /// The FINALIZE into the lock (T3); its txid is `lock_txid`. + pub lock_finalize_draft_id: Option, pub lock_transfer_txid: Option, pub lock_txid: Option, pub lock_vout: Option, @@ -1625,8 +1639,8 @@ pub struct ShakedexListing { } const SHAKEDEX_LISTING_COLS: &str = "id, wallet_profile_id, name, mode, state, lock_pubkey_hex, \ - lock_transfer_draft_id, lock_transfer_txid, lock_txid, lock_vout, payment_address, \ - cancel_address, cancel_child_index, steps_json, listing_file_json, publish, market_status, \ + lock_transfer_draft_id, lock_finalize_draft_id, lock_transfer_txid, lock_txid, lock_vout, \ + payment_address, cancel_address, cancel_child_index, steps_json, listing_file_json, publish, market_status, \ market_retry_at, expires_at, abort_draft_id, abort_txid, sold_txid, cancel_txid, created_at, \ updated_at"; @@ -1639,6 +1653,7 @@ fn row_to_shakedex_listing(row: &rusqlite::Row<'_>) -> rusqlite::Result Result )?) } +/// The columns a Finalizing listing loses when its FINALIZE never landed: +/// the lock outpoint, the steps and the file were signed over a coin that +/// does not exist. Shared by [`revert_listing_to_ready`] and the deletion of +/// an unsent FINALIZE draft, so the two cannot drift. +const REVERT_TO_READY_SET: &str = "state = 'ready_to_finalize', lock_finalize_draft_id = NULL, \ + lock_txid = NULL, lock_vout = NULL, steps_json = '[]', listing_file_json = NULL, \ + expires_at = NULL, updated_at = datetime('now')"; + +/// What Finalize & sign writes on its listing (R19, R23). +pub struct FinalizingListing<'a> { + pub finalize_draft_id: &'a str, + /// The FINALIZE's txid; the lock coin is its output 0. + pub lock_txid: &'a str, + pub steps_json: &'a str, + pub listing_file_json: &'a str, + pub expires_at: u64, +} + +/// R19: a listing ReadyToFinalize becomes Finalizing with its FINALIZE +/// draft, lock outpoint, steps and listing file, inside the caller's +/// transaction (the one that inserts the draft), so it leaves +/// `CANCEL_ABORTABLE` together with the draft. Returns 0 when the listing is +/// not ReadyToFinalize (changed meanwhile): the caller rolls back. +pub fn mark_listing_finalizing_in_tx( + tx: &rusqlite::Transaction<'_>, + id: &str, + f: &FinalizingListing, +) -> Result { + let expires_at = i64::try_from(f.expires_at) + .map_err(|_| AppError::Other("listing expiry out of range".into()))?; + Ok(tx.execute( + "UPDATE shakedex_listings + SET state = ?2, lock_finalize_draft_id = ?3, lock_txid = ?4, lock_vout = 0, + steps_json = ?5, listing_file_json = ?6, expires_at = ?7, + updated_at = datetime('now') + WHERE id = ?1 AND state = ?8", + params![ + id, + ListingState::Finalizing, + f.finalize_draft_id, + f.lock_txid, + f.steps_json, + f.listing_file_json, + expires_at, + ListingState::ReadyToFinalize + ], + )?) +} + +/// A Finalizing listing whose FINALIZE never landed goes back to +/// ReadyToFinalize, losing its lock outpoint, steps and file. Returns how +/// many rows changed (0 or 1). +pub fn revert_listing_to_ready(conn: &rusqlite::Connection, id: &str) -> Result { + Ok(conn.execute( + &format!("UPDATE shakedex_listings SET {REVERT_TO_READY_SET} WHERE id = ?1 AND state = ?2"), + params![id, ListingState::Finalizing], + )?) +} + +/// `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], + )?) +} + +/// 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, + ) +} + +/// A reorg undid the lock TRANSFER's lockup: ReadyToFinalize to Locking. +pub fn mark_listing_locking_again( + conn: &rusqlite::Connection, + id: &str, +) -> Result { + move_listing( + conn, + id, + ListingState::ReadyToFinalize, + ListingState::Locking, + ) +} + +/// 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) +} + +/// A reorg took the FINALIZE out of the chain: Listed to Finalizing. +pub fn mark_listing_finalizing_again( + conn: &rusqlite::Connection, + id: &str, +) -> Result { + move_listing(conn, id, ListingState::Listed, ListingState::Finalizing) +} + +/// R19 (coordinator (b)): the name was finalized into this listing's lock by +/// a FINALIZE this device did not build (another device with the same seed): +/// the listing tracks that lock coin as a Restored lock, never Aborted. Only +/// from `CANCEL_ABORTABLE`. Returns how many rows changed (0 or 1). +pub fn adopt_lock_finalized_elsewhere( + conn: &rusqlite::Connection, + id: &str, + lock_txid: &str, + lock_vout: u32, +) -> Result { + let sql = format!( + "UPDATE shakedex_listings + SET state = ?2, lock_txid = ?3, lock_vout = ?4, updated_at = datetime('now') + WHERE id = ?1 AND state IN {}", + ListingState::cancel_abortable_sql() + ); + Ok(conn.execute( + &sql, + params![id, ListingState::Restored, 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( + conn: &rusqlite::Connection, + profile_id: &str, +) -> Result, AppError> { + let sql = format!( + "SELECT {SHAKEDEX_LISTING_COLS} FROM shakedex_listings + WHERE wallet_profile_id = ?1 AND state IN (?2, ?3) + ORDER BY created_at, id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![profile_id, ListingState::Finalizing, ListingState::Listed], + row_to_shakedex_listing, + )?; + Ok(rows.collect::, _>>()?) +} + +/// `(profile, name, lock transfer txid)` of every ReadyToFinalize listing, +/// across profiles (the `listing_ready` reminder). +pub fn list_listings_ready_to_finalize( + conn: &rusqlite::Connection, +) -> Result, AppError> { + let mut stmt = conn.prepare( + "SELECT wallet_profile_id, name, lock_transfer_txid FROM shakedex_listings + WHERE state = ?1 AND lock_transfer_txid IS NOT NULL + ORDER BY wallet_profile_id, name", + )?; + let rows = stmt.query_map(params![ListingState::ReadyToFinalize], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + })?; + Ok(rows.collect::, _>>()?) +} + /// A purchase's state with the chain facts tracked alongside it; written /// together by [`update_shakedex_purchase_state`]. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-tauri/src/sql/035_shakedex_listing_finalize.sql b/src-tauri/src/sql/035_shakedex_listing_finalize.sql new file mode 100644 index 0000000..429126a --- /dev/null +++ b/src-tauri/src/sql/035_shakedex_listing_finalize.sql @@ -0,0 +1,4 @@ +-- T3: the FINALIZE into the lock built by Finalize & sign. Its txid is the +-- listing's lock_txid (output 0); the draft is kept to tell an unsent or +-- refused FINALIZE from one that may still land. +ALTER TABLE shakedex_listings ADD COLUMN lock_finalize_draft_id TEXT; diff --git a/src-tauri/src/tests/connection_tests.rs b/src-tauri/src/tests/connection_tests.rs index ac9bd8b..d7e33f3 100644 --- a/src-tauri/src/tests/connection_tests.rs +++ b/src-tauri/src/tests/connection_tests.rs @@ -39,11 +39,11 @@ fn test_migrations_run_idempotent() { // Running migrations again should be a no-op (idempotent). crate::db::migrations::run(&conn).unwrap(); - // Verify all 34 migrations are recorded. + // Verify all 35 migrations are recorded. let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |row| row.get(0)) .unwrap(); - assert_eq!(count, 34); + assert_eq!(count, 35); } #[test] diff --git a/src-tauri/src/tests/migration_tests.rs b/src-tauri/src/tests/migration_tests.rs index 3ddc3cc..766c9f9 100644 --- a/src-tauri/src/tests/migration_tests.rs +++ b/src-tauri/src/tests/migration_tests.rs @@ -72,7 +72,7 @@ fn test_schema_version_tracking() { // outranked the network default on every other network). // 033 (shakedex_purchases: buyer-side Shakedex purchase tracking). // 034 (shakedex_listings: seller-side Shakedex listing tracking). - assert_eq!(count, 34); + assert_eq!(count, 35); } #[test] diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index b824de3..f655371 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -36,6 +36,7 @@ fn listing(id: &str, name: &str, state: ListingState) -> ShakedexListing { state, lock_pubkey_hex: "02".repeat(33), lock_transfer_draft_id: None, + lock_finalize_draft_id: None, lock_transfer_txid: None, lock_txid: None, lock_vout: None, @@ -365,6 +366,239 @@ fn deleting_another_draft_keeps_a_locking_listing() { .is_some()); } +fn insert_draft(conn: &Connection, id: &str, action: &str, status: &str) { + queries::insert_tx_draft(conn, id, STORE_PROFILE, action, "00", "{}", "{}").unwrap(); + conn.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = ?2", + params![status, id], + ) + .unwrap(); +} + +fn finalizing(conn: &Connection, id: &str, draft_id: &str) -> usize { + let tx = conn.unchecked_transaction().unwrap(); + let n = queries::mark_listing_finalizing_in_tx( + &tx, + id, + &queries::FinalizingListing { + finalize_draft_id: draft_id, + lock_txid: &"f1".repeat(32), + steps_json: r#"[{"price":5000000,"lockTime":1,"signature":"ab"}]"#, + listing_file_json: "{}", + expires_at: 1_731_536_000, + }, + ) + .unwrap(); + tx.commit().unwrap(); + n +} + +/// R19: only a listing ReadyToFinalize becomes Finalizing, and it then +/// carries its FINALIZE draft, the lock outpoint (output 0 of the FINALIZE), +/// the steps, the listing file and its expiry. +#[test] +fn finalizing_is_written_only_from_ready_to_finalize() { + for state in ListingState::ALL { + let conn = store_conn(); + queries::insert_shakedex_listing(&conn, &listing("l1", "dexsale", state)).unwrap(); + insert_draft(&conn, "fin", sell::LOCK_FINALIZE_ACTION, "signed"); + let n = finalizing(&conn, "l1", "fin"); + let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); + if state == ListingState::ReadyToFinalize { + assert_eq!(n, 1); + assert_eq!(l.state, ListingState::Finalizing); + assert!( + !l.state.aborts_by_cancel_transfer(), + "out of CANCEL_ABORTABLE" + ); + assert_eq!(l.lock_finalize_draft_id.as_deref(), Some("fin")); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some("f1".repeat(32).as_str()), Some(0)) + ); + assert!(l.steps_json.contains("5000000")); + assert_eq!(l.listing_file_json.as_deref(), Some("{}")); + assert_eq!(l.expires_at, Some(1_731_536_000)); + } else { + assert_eq!(n, 0, "{state:?}"); + assert_eq!(l.state, state); + assert_eq!(l.lock_finalize_draft_id, None); + assert_eq!(l.lock_txid, None); + assert_eq!(l.steps_json, "[]"); + } + } +} + +/// Deviation 3: an unsent FINALIZE (draft or signed) deleted takes its lock +/// outpoint, steps and file with it; the listing is ReadyToFinalize again. +#[test] +fn deleting_an_unsent_finalize_draft_returns_the_listing_to_ready() { + for status in ["draft", "signed"] { + let conn = store_conn(); + queries::insert_shakedex_listing( + &conn, + &listing("l1", "dexsale", ListingState::ReadyToFinalize), + ) + .unwrap(); + insert_draft(&conn, "fin", sell::LOCK_FINALIZE_ACTION, status); + assert_eq!(finalizing(&conn, "l1", "fin"), 1); + queries::delete_tx_draft(&conn, "fin").unwrap(); + let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); + assert_eq!(l.state, ListingState::ReadyToFinalize, "{status}"); + assert_eq!( + (l.lock_txid, l.lock_vout, l.lock_finalize_draft_id), + (None, None, None) + ); + assert_eq!(l.steps_json, "[]"); + assert_eq!((l.listing_file_json, l.expires_at), (None, None)); + } +} + +/// A FINALIZE that was sent (`failed`, `dropped`) may be deleted, but its +/// listing keeps its outpoint and steps: whether it landed is the chain's to +/// say (Step 6). +#[test] +fn deleting_a_failed_finalize_draft_keeps_its_steps_until_the_chain_says() { + for status in ["failed", "dropped"] { + let conn = store_conn(); + queries::insert_shakedex_listing( + &conn, + &listing("l1", "dexsale", ListingState::ReadyToFinalize), + ) + .unwrap(); + insert_draft(&conn, "fin", sell::LOCK_FINALIZE_ACTION, "signed"); + assert_eq!(finalizing(&conn, "l1", "fin"), 1); + conn.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = 'fin'", + [status], + ) + .unwrap(); + queries::delete_tx_draft(&conn, "fin").unwrap(); + let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); + assert_eq!(l.state, ListingState::Finalizing, "{status}"); + assert!(l.lock_txid.is_some()); + assert!(l.steps_json.contains("5000000")); + } +} + +/// Every other transition writes only from its expected previous state and +/// reports 0 rows otherwise. +#[test] +fn listing_transitions_write_only_from_their_previous_state() { + type Transition = fn(&Connection, &str) -> Result; + let cases: [(&str, Transition, ListingState, ListingState); 5] = [ + ( + "ready", + queries::mark_listing_ready, + ListingState::Locking, + ListingState::ReadyToFinalize, + ), + ( + "again", + queries::mark_listing_locking_again, + ListingState::ReadyToFinalize, + ListingState::Locking, + ), + ( + "listed", + queries::mark_listing_listed, + ListingState::Finalizing, + ListingState::Listed, + ), + ( + "fin again", + queries::mark_listing_finalizing_again, + ListingState::Listed, + ListingState::Finalizing, + ), + ( + "revert", + queries::revert_listing_to_ready, + ListingState::Finalizing, + ListingState::ReadyToFinalize, + ), + ]; + for (label, f, from, to) in cases { + for state in ListingState::ALL { + let conn = store_conn(); + queries::insert_shakedex_listing(&conn, &listing("l1", "dexsale", state)).unwrap(); + let n = f(&conn, "l1").unwrap(); + let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); + if state == from { + assert_eq!((n, l.state), (1, to), "{label}"); + } else { + assert_eq!((n, l.state), (0, state), "{label} from {state:?}"); + } + } + } +} + +/// A FINALIZE into our lock from another device: only a listing whose abort +/// is still the Cancel transfer adopts the coin, as Restored. +#[test] +fn finalized_elsewhere_is_adopted_only_before_the_lock() { + for state in ListingState::ALL { + let conn = store_conn(); + queries::insert_shakedex_listing(&conn, &listing("l1", "dexsale", state)).unwrap(); + let n = queries::adopt_lock_finalized_elsewhere(&conn, "l1", &"ab".repeat(32), 0).unwrap(); + let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); + if state.aborts_by_cancel_transfer() { + assert_eq!((n, l.state), (1, ListingState::Restored)); + assert_eq!(l.lock_txid, Some("ab".repeat(32))); + assert_eq!(l.lock_vout, Some(0)); + } else { + assert_eq!((n, l.state), (0, state), "{state:?}"); + assert_eq!(l.lock_txid, None); + } + } +} + +/// The Finalizing/Listed source lists exactly those two states of a profile. +#[test] +fn finalizing_and_listed_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), + ] { + queries::insert_shakedex_listing(&conn, &listing(id, name, state)).unwrap(); + } + let ids: Vec = queries::list_shakedex_listings_finalizing(&conn, STORE_PROFILE) + .unwrap() + .into_iter() + .map(|l| l.id) + .collect(); + assert_eq!(ids, ["l1", "l2"]); + assert!(queries::list_shakedex_listings_finalizing(&conn, "other") + .unwrap() + .is_empty()); +} + +/// The deadline scan's source: ReadyToFinalize listings only. +#[test] +fn listings_ready_to_finalize_are_listed_for_the_reminder() { + let conn = store_conn(); + for (id, name, state) in [ + ("l1", "ready", ListingState::ReadyToFinalize), + ("l2", "locking", ListingState::Locking), + ("l3", "fin", ListingState::Finalizing), + ] { + let mut l = listing(id, name, state); + l.lock_transfer_txid = Some(format!("{id}-tx")); + queries::insert_shakedex_listing(&conn, &l).unwrap(); + } + assert_eq!( + queries::list_listings_ready_to_finalize(&conn).unwrap(), + vec![( + STORE_PROFILE.to_string(), + "ready".to_string(), + "l1-tx".to_string() + )] + ); +} + // --- the lock command ------------------------------------------------------- use mockito::{Mock, ServerGuard}; From 73e9e85a065add85a20bfacaad7e5a8d4a585515 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 15:17:29 +0200 Subject: [PATCH 05/17] fix(shakedex): pin the FINALIZE-delete revert to its own listing and clear the cancel link when a lock is adopted --- src-tauri/src/db/queries.rs | 58 ++++++++++++---------- src-tauri/src/tests/shakedex_sell_tests.rs | 19 ++++++- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index b8cc4c7..9b6a9eb 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1348,38 +1348,33 @@ pub fn delete_tx_draft_in_tx(tx: &rusqlite::Transaction<'_>, id: &str) -> Result WHERE purchase_draft_id = ?1 AND state = ?2", params![id, PurchaseState::PendingSend], )?; - // A listing still Locking goes with its lock TRANSFER draft only when the - // draft was never sent (`draft`, `signed`): the name never left; its - // reserved addresses stay used. A `dropped` or `failed` draft was - // broadcast and may still be mined, so its listing stays. + tx.execute( + "UPDATE shakedex_purchases SET finalize_draft_id = NULL, updated_at = datetime('now') + WHERE finalize_draft_id = ?1", + params![id], + )?; + // A draft never sent (`draft`, `signed`) leaves the chain untouched; a + // `dropped` or `failed` one was broadcast and may still be mined, so the + // listings below keep their state and the chain judges them. if never_sent(&status) { + // A listing still Locking goes with its lock TRANSFER draft: the name + // never left; its reserved addresses stay used. tx.execute( "DELETE FROM shakedex_listings WHERE lock_transfer_draft_id = ?1 AND state = ?2", params![id, ListingState::Locking], )?; - } - // An unsent FINALIZE into the lock takes its steps with it: they were - // signed over a coin that never existed. A sent one (`dropped`, `failed`) - // may still land, so its listing keeps everything for the chain to judge. - if never_sent(&status) { + // An unsent FINALIZE into the lock takes its steps with it: they were + // signed over a coin that never existed. tx.execute( &format!( - "UPDATE shakedex_listings SET {REVERT_TO_READY_SET} - WHERE lock_finalize_draft_id = ?1 AND state = ?2" + "UPDATE shakedex_listings SET {} + WHERE lock_finalize_draft_id = ?1 AND state = ?2", + revert_to_ready_set() ), params![id, ListingState::Finalizing], )?; - } - tx.execute( - "UPDATE shakedex_purchases SET finalize_draft_id = NULL, updated_at = datetime('now') - WHERE finalize_draft_id = ?1", - params![id], - )?; - // A Cancel transfer never sent (`draft`, `signed`) aborts nothing, so its - // listing loses the link and the cancel's txid (R19). A `dropped` or - // `failed` one was broadcast and may still be mined: its listing keeps - // both, and the abort job reads the txid from the listing, not the draft. - if never_sent(&status) { + // A Cancel transfer aborts nothing, so its listing loses the link and + // the cancel's txid (R19). tx.execute( "UPDATE shakedex_listings SET abort_draft_id = NULL, abort_txid = NULL, updated_at = datetime('now') @@ -1915,9 +1910,14 @@ pub fn unabort_shakedex_listing(conn: &rusqlite::Connection, id: &str) -> Result /// the lock outpoint, the steps and the file were signed over a coin that /// does not exist. Shared by [`revert_listing_to_ready`] and the deletion of /// an unsent FINALIZE draft, so the two cannot drift. -const REVERT_TO_READY_SET: &str = "state = 'ready_to_finalize', lock_finalize_draft_id = NULL, \ - lock_txid = NULL, lock_vout = NULL, steps_json = '[]', listing_file_json = NULL, \ - expires_at = NULL, updated_at = datetime('now')"; +fn revert_to_ready_set() -> String { + format!( + "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() + ) +} /// What Finalize & sign writes on its listing (R19, R23). pub struct FinalizingListing<'a> { @@ -1965,7 +1965,10 @@ pub fn mark_listing_finalizing_in_tx( /// many rows changed (0 or 1). pub fn revert_listing_to_ready(conn: &rusqlite::Connection, id: &str) -> Result { Ok(conn.execute( - &format!("UPDATE shakedex_listings SET {REVERT_TO_READY_SET} WHERE id = ?1 AND state = ?2"), + &format!( + "UPDATE shakedex_listings SET {} WHERE id = ?1 AND state = ?2", + revert_to_ready_set() + ), params![id, ListingState::Finalizing], )?) } @@ -2033,7 +2036,8 @@ pub fn adopt_lock_finalized_elsewhere( ) -> Result { let sql = format!( "UPDATE shakedex_listings - SET state = ?2, lock_txid = ?3, lock_vout = ?4, updated_at = datetime('now') + SET state = ?2, lock_txid = ?3, lock_vout = ?4, abort_draft_id = NULL, + abort_txid = NULL, updated_at = datetime('now') WHERE id = ?1 AND state IN {}", ListingState::cancel_abortable_sql() ); diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index f655371..88ef0cb 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -442,7 +442,19 @@ fn deleting_an_unsent_finalize_draft_returns_the_listing_to_ready() { .unwrap(); insert_draft(&conn, "fin", sell::LOCK_FINALIZE_ACTION, status); assert_eq!(finalizing(&conn, "l1", "fin"), 1); + // Another listing with its own FINALIZE draft is out of scope. + queries::insert_shakedex_listing( + &conn, + &listing("l2", "other", ListingState::ReadyToFinalize), + ) + .unwrap(); + insert_draft(&conn, "fin2", sell::LOCK_FINALIZE_ACTION, "signed"); + assert_eq!(finalizing(&conn, "l2", "fin2"), 1); queries::delete_tx_draft(&conn, "fin").unwrap(); + let o = queries::get_shakedex_listing(&conn, "l2").unwrap().unwrap(); + assert_eq!(o.state, ListingState::Finalizing, "{status}"); + assert!(o.lock_txid.is_some() && o.steps_json.contains("5000000")); + assert_eq!(o.lock_finalize_draft_id.as_deref(), Some("fin2")); let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); assert_eq!(l.state, ListingState::ReadyToFinalize, "{status}"); assert_eq!( @@ -539,15 +551,20 @@ fn listing_transitions_write_only_from_their_previous_state() { fn finalized_elsewhere_is_adopted_only_before_the_lock() { for state in ListingState::ALL { let conn = store_conn(); - queries::insert_shakedex_listing(&conn, &listing("l1", "dexsale", state)).unwrap(); + let mut l = listing("l1", "dexsale", state); + l.abort_draft_id = Some("cancel".into()); + l.abort_txid = Some("cd".repeat(32)); + queries::insert_shakedex_listing(&conn, &l).unwrap(); let n = queries::adopt_lock_finalized_elsewhere(&conn, "l1", &"ab".repeat(32), 0).unwrap(); let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); if state.aborts_by_cancel_transfer() { assert_eq!((n, l.state), (1, ListingState::Restored)); assert_eq!(l.lock_txid, Some("ab".repeat(32))); assert_eq!(l.lock_vout, Some(0)); + assert_eq!((l.abort_draft_id, l.abort_txid), (None, None)); } else { assert_eq!((n, l.state), (0, state), "{state:?}"); + assert_eq!(l.abort_draft_id.as_deref(), Some("cancel")); assert_eq!(l.lock_txid, None); } } From 0e1f95ef86e292e5642890cb37c528142d03584d Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 15:34:57 +0200 Subject: [PATCH 06/17] feat(shakedex): check the lock transfer before Finalize & sign (R18, R31) prepare_lock_finalize runs every gate of Finalize & sign and builds the FINALIZE into the lock without writing or asking anything: the Shakedex profile and node gates (no experimental flag, it acts on an existing listing), the unlocked signer before any read, a ReadyToFinalize Buy Now listing of the active profile, no live Cancel transfer linked to the listing (both spend the same TRANSFER coin), exactly one price, the name owned by an unrevoked (lock transfer, 0) on the node, that coin mined and unspent, the lockup over at tip + 1, R31 with the remaining lockup, and R18 against the covenant hsd reports for the coin, compared with the lock address of the key re-derived after the reads. Every node field read is matched on its own and is "could not check" when missing. The two signer blocks of shakedex_build_lock_draft become authorize_signer and derive_listing_key, shared by both commands. Gates: cargo fmt --check, cargo clippy --all-targets -D warnings, cargo test --locked (3083 + 3 passed); 22 mutations, each caught. --- src-tauri/src/commands/shakedex.rs | 301 +++++++- src-tauri/src/db/queries.rs | 10 +- src-tauri/src/noncustodial/shakedex/sell.rs | 5 + src-tauri/src/tests/shakedex_sell_tests.rs | 722 +++++++++++++++++++- 4 files changed, 1016 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/shakedex.rs b/src-tauri/src/commands/shakedex.rs index e0bcb16..801b2e1 100644 --- a/src-tauri/src/commands/shakedex.rs +++ b/src-tauri/src/commands/shakedex.rs @@ -23,7 +23,7 @@ use crate::noncustodial::actions::{self, PrimaryOutput}; use crate::noncustodial::derivation; use crate::noncustodial::network::Network; use crate::noncustodial::rpc::{self, ChainSource, NodeRpcClient}; -use crate::noncustodial::send::{self, DUST_THRESHOLD}; +use crate::noncustodial::send::{self, SpendableCoin, DUST_THRESHOLD}; use crate::noncustodial::session::session_ttl_ms; use crate::noncustodial::shakedex::listing_file::{ListingFile, MAX_LISTING_FILE_BYTES}; use crate::noncustodial::shakedex::lock_key::{derive_lock_key, LockKey}; @@ -639,6 +639,286 @@ pub(crate) fn build_lock_draft_inner( draft_ctx::draft_summary(conn, &draft_id) } +/// The unlocked signer session of the active profile, or `WalletLocked`. +/// Checked before a selling command reads anything else. +fn authorize_signer(state: &State<'_, AppState>, ctx: &Ctx) -> Result<(), AppError> { + let mut slot = state + .signer + .lock() + .map_err(|e| AppError::Lock(e.to_string()))?; + let session = slot.as_mut().ok_or(AppError::WalletLocked)?; + session.authorize(&ctx.profile_id, session_ttl_ms(&ctx.settings)) +} + +/// The lock key of `name` (ADR 0004), derived from the seed of the unlocked +/// session, which is authorized again. Called once every node read is done, +/// just before the key is used; it is never stored. +fn derive_listing_key( + state: &State<'_, AppState>, + ctx: &Ctx, + name: &str, +) -> Result { + let mut slot = state + .signer + .lock() + .map_err(|e| AppError::Lock(e.to_string()))?; + let session = slot.as_mut().ok_or(AppError::WalletLocked)?; + session.authorize(&ctx.profile_id, session_ttl_ms(&ctx.settings))?; + derive_lock_key(session.master()?, ctx.network, ctx.account, name) +} + +/// One price step the seller typed (R19): HNS as text, so the backend can +/// refuse what it cannot represent. T8 adds a schedule. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StepInput { + pub price: String, +} + +/// Everything Finalize & sign has checked and built before it asks (R20). +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) struct PreparedLockFinalize { + pub(crate) ctx: Ctx, + pub(crate) listing: ShakedexListing, + pub(crate) key: LockKey, + /// The FINALIZE into the lock; the lock coin is `(plan.txid, 0)`. + pub(crate) plan: actions::PlanResult, + /// The value of the lock coin (the name's coin, carried by output 0). + pub(crate) lock_value: u64, + pub(crate) prices: Vec, + /// R19: the Buy Now lock time, from the MTP at signing. + pub(crate) lock_time: u64, + pub(crate) mtp: u64, + pub(crate) payment_address: String, +} + +/// "could not check" for a field of the node's reply that Finalize & sign +/// reads. +fn could_not_check(what: &str, name: &str) -> AppError { + AppError::Rpc(format!( + "node did not report {what}: could not check '{name}' before Finalize & sign" + )) +} + +/// R18, R19, R31 at Finalize & sign: every gate and node read, then the lock +/// key (after the reads) and the FINALIZE plan. Writes nothing and asks +/// nothing; a refusal leaves the listing ReadyToFinalize. Every node field +/// it reads is matched on its own: a missing one is "could not check". +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) async fn prepare_lock_finalize( + state: &State<'_, AppState>, + listing_id: &str, + prices: &[StepInput], + fee_rate: Option, +) -> Result { + // R16, R6. No experimental flag: this acts on an existing listing (R15). + let ctx = software_writer_ctx(state)?; + authorize_signer(state, &ctx)?; + let (listing, owner) = { + let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; + let listing = queries::get_shakedex_listing(&conn, listing_id)? + .filter(|l| l.wallet_profile_id == ctx.profile_id) + .ok_or_else(|| AppError::NotFound(format!("listing {listing_id}")))?; + match listing.state { + ListingState::ReadyToFinalize => {} + ListingState::Locking => { + return Err(AppError::InvalidInput( + "Finalize & sign opens after the transfer lockup".into(), + )) + } + _ => { + return Err(AppError::InvalidInput( + "this listing is already finalized into its lock".into(), + )) + } + } + if listing.mode != ListingMode::BuyNow { + return Err(AppError::InvalidInput( + "reverse auctions are not supported yet".into(), + )); + } + // The Cancel transfer and the FINALIZE spend the same TRANSFER coin. + if let Some(cancel_id) = &listing.abort_draft_id { + if let Some(cancel) = queries::get_tx_draft(&conn, cancel_id)? { + if queries::draft_may_still_land(&cancel.status) { + return Err(AppError::InvalidInput(sell::CANCEL_TRANSFER_PENDING.into())); + } + } + } + let owner = queries::get_name_coin(&conn, &ctx.profile_id, &listing.name)?; + (listing, owner) + }; + if prices.len() != 1 { + return Err(AppError::InvalidInput( + "a Buy Now listing has exactly one price".into(), + )); + } + let prices = prices + .iter() + .map(|p| sell::parse_step_price(&p.price)) + .collect::, _>>()?; + let corrupted = |what: &str| AppError::Other(format!("corrupted listing: no {what}")); + let lock_transfer_txid = listing + .lock_transfer_txid + .clone() + .ok_or_else(|| corrupted("lock transfer"))?; + let payment_address = listing + .payment_address + .clone() + .ok_or_else(|| corrupted("payment address"))?; + let owner = owner + .filter(|o| { + o.txid.eq_ignore_ascii_case(&lock_transfer_txid) + && o.vout == 0 + && o.covenant_type == i64::from(COV_TRANSFER) + }) + .ok_or_else(|| { + AppError::InvalidInput( + "the wallet has not seen the lock transfer as the name's coin yet (sync?)".into(), + ) + })?; + + // One getnameinfo reply and one tip for every check. + let name = listing.name.clone(); + let reply = ctx.node.get_name_info(&name).await?; + let chain = ctx + .node + .get_blockchain_info() + .await + .map_err(|e| could_not_check(&format!("its tip ({e})"), &name))?; + let tip = chain.blocks; + let Some(mtp) = chain.mediantime else { + return Err(AppError::Rpc( + "node did not report its median time: the price could not be signed".into(), + )); + }; + let info = match reply.get("info") { + None => return Err(could_not_check("the name's info", &name)), + Some(serde_json::Value::Null) => { + return Err(AppError::InvalidInput(format!( + "'{name}' has no on-chain state or has expired" + ))) + } + Some(i) => i, + }; + let Some(owner_hash) = info + .get("owner") + .and_then(|o| o.get("hash")) + .and_then(|h| h.as_str()) + else { + return Err(could_not_check("the name's owner", &name)); + }; + let Some(owner_index) = info + .get("owner") + .and_then(|o| o.get("index")) + .and_then(|i| i.as_u64()) + else { + return Err(could_not_check("the name's owner output", &name)); + }; + let Some(revoked) = info.get("revoked").and_then(|r| r.as_u64()) else { + return Err(could_not_check("whether the name was revoked", &name)); + }; + if revoked != 0 || owner_index != 0 || !owner_hash.eq_ignore_ascii_case(&lock_transfer_txid) { + return Err(AppError::InvalidInput(format!( + "'{name}' is no longer held by its lock transfer: there is nothing to finalize" + ))); + } + let coin = ctx + .node + .get_coin(&lock_transfer_txid, 0) + .await? + .ok_or_else(|| { + AppError::InvalidInput( + "the lock transfer is no longer unspent: the name may already be finalized".into(), + ) + })?; + let transfer_height = coin + .mined_height() + .map_err(|_| could_not_check("the lock transfer's height", &name))? + .ok_or_else(|| AppError::InvalidInput("the lock transfer is not mined yet".into()))?; + let params = ctx.network.name_params(); + let remaining = params.blocks_until_finalize(transfer_height, tip); + if remaining > 0 { + return Err(AppError::InvalidInput(format!( + "the transfer lockup ends in {remaining} block{}", + if remaining == 1 { "" } else { "s" } + ))); + } + // R31 with what is left of the lockup (0 here): the FINALIZE renews the + // name. + if let ExpiryNotice::Refuse { expiry_end } = lock_expiry_guard(¶ms, &reply, tip, remaining)? + { + return Err(sell::expires_before_finalize(&name, expiry_end)); + } + let ns = draft_ctx::name_state_strict(&reply, &name)?; + let cov_height = coin + .covenant + .as_ref() + .and_then(purchase::covenant_name_height) + .ok_or_else(|| could_not_check("a readable name height in the lock transfer", &name))?; + if cov_height != ns.height { + return Err(AppError::InvalidInput( + "the name expired and was registered again since the lock: it cannot be finalized" + .into(), + )); + } + let lock_value = u64::try_from(coin.value) + .map_err(|_| AppError::Rpc(format!("bad coin value {}", coin.value)))?; + if lock_value != owner.value || coin.address.as_deref() != Some(owner.address.as_str()) { + return Err(AppError::Rpc( + "the node and the wallet disagree on the lock transfer coin (sync?)".into(), + )); + } + let renewal_block = draft_ctx::renewal_block(&ctx.node, ctx.network).await?; + let rate = draft_ctx::fee_rate(&ctx, fee_rate); + + // R18: re-derive the key now, after every read, and check the node's + // confirmed TRANSFER commits to SHA3-256 of its lock script. + let key = derive_listing_key(state, &ctx, &name)?; + sell::lock_self_check(&key, ctx.network)?; + if hex::encode(key.pubkey) != listing.lock_pubkey_hex + || !purchase::transfer_commits_to(&coin, ctx.network, &key.address)? + { + return Err(AppError::InvalidInput( + sell::LOCK_COMMITMENT_MISMATCH.into(), + )); + } + let plan = sell::build_lock_finalize_plan(&sell::LockFinalizeInput { + network: ctx.network, + account: ctx.account, + transfer: &SpendableCoin { + txid: owner.txid.clone(), + vout: owner.vout, + value: owner.value, + branch: owner.branch, + child_index: owner.child_index, + }, + lock_pubkey: key.pubkey, + name: &name, + name_height: ns.height, + weak: ns.weak, + claimed: ns.claimed, + renewals: ns.renewals, + renewal_block, + funding: &ctx.funding, + change_address: &ctx.change_address, + rate, + #[cfg(test)] + fixed_fee: None, + })?; + Ok(PreparedLockFinalize { + ctx, + listing, + key, + plan, + lock_value, + prices, + lock_time: sell::buy_now_lock_time(mtp), + mtp, + payment_address, + }) +} + // --- commands --------------------------------------------------------------- /// One page of LearnHNS Market listings, each verified on the profile's node. @@ -1145,14 +1425,7 @@ pub async fn shakedex_build_lock_draft( } // The signer is checked before anything else is read; the key itself is // derived only once every read is done, just before it is used. - { - let mut slot = state - .signer - .lock() - .map_err(|e| AppError::Lock(e.to_string()))?; - let session = slot.as_mut().ok_or(AppError::WalletLocked)?; - session.authorize(&ctx.profile_id, session_ttl_ms(&ctx.settings))?; - } + authorize_signer(&state, &ctx)?; let owner = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; queries::get_name_coin(&conn, &ctx.profile_id, &name)? @@ -1175,15 +1448,7 @@ pub async fn shakedex_build_lock_draft( let notice = lock_expiry_guard(¶ms, &reply, tip, i64::from(params.transfer_lockup))?; let ns = draft_ctx::name_state_strict(&reply, &name)?; let rate = draft_ctx::fee_rate(&ctx, fee_rate); - let key = { - let mut slot = state - .signer - .lock() - .map_err(|e| AppError::Lock(e.to_string()))?; - let session = slot.as_mut().ok_or(AppError::WalletLocked)?; - session.authorize(&ctx.profile_id, session_ttl_ms(&ctx.settings))?; - derive_lock_key(session.master()?, ctx.network, ctx.account, &name)? - }; + let key = derive_listing_key(&state, &ctx, &name)?; let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; build_lock_draft_inner( &conn, diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index 9b6a9eb..c42ade8 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1291,7 +1291,15 @@ pub fn lock_draft_holds_name(status: &str) -> bool { /// holds the name ([`lock_draft_holds_name`]) and is not mined yet. Until it /// is mined or given up, a missing lock coin proves nothing (R19). pub fn lock_draft_may_still_land(status: &str) -> bool { - lock_draft_holds_name(status) && status != CONFIRMED_STATUS + draft_may_still_land(status) +} + +/// Whether a draft in `status` may still be mined: unsent ([`never_sent`]), +/// or sent and neither mined nor given up (`dropped`, `failed`). A Cancel +/// transfer in such a status holds back the FINALIZE into the lock, which +/// spends the same coin. +pub fn draft_may_still_land(status: &str) -> bool { + (never_sent(status) || may_have_reached_chain(status)) && status != CONFIRMED_STATUS } /// `items` quoted as an SQL list, `('a', 'b')`. Only for the fixed spellings diff --git a/src-tauri/src/noncustodial/shakedex/sell.rs b/src-tauri/src/noncustodial/shakedex/sell.rs index b3e504a..ad11769 100644 --- a/src-tauri/src/noncustodial/shakedex/sell.rs +++ b/src-tauri/src/noncustodial/shakedex/sell.rs @@ -264,6 +264,11 @@ pub const STEP_SIGNATURE_PERMANENCE: &str = "Each signature lets anyone buy the pub const LOCK_COMMITMENT_MISMATCH: &str = "the lock transfer does not commit to this \ wallet's lock for the name: nothing was finalized or signed"; +/// Finalize & sign is refused while the listing's Cancel transfer may still +/// be mined: both spend the lock TRANSFER coin. +pub const CANCEL_TRANSFER_PENDING: &str = "a Cancel transfer of this listing is pending and \ + spends the same coin: Finalize & sign waits until it is dropped, failed or deleted"; + /// R19: the price a person typed, in HNS, as doos. Refused, with the reason: /// not a plain decimal, more than 6 decimals, 0, below the dust limit, above /// the money supply. Never rounds. diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index 88ef0cb..acce430 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -207,6 +207,7 @@ fn draft_status_sets_partition_the_statuses() { may_land, "{status}" ); + assert_eq!(queries::draft_may_still_land(status), may_land, "{status}"); assert!( !(unsent && queries::may_have_reached_chain(status)), "{status}" @@ -636,8 +637,8 @@ use crate::noncustodial::shakedex::sell::{self, LOCK_ACTION, LOCK_COSTS}; use crate::noncustodial::sync::{COV_REGISTER, COV_REVEAL, COV_TRANSFER}; use crate::noncustodial::types::TxSummary; use crate::tests::shakedex_cmd_tests::{ - app_with, err_text, mock_blockchain_info, mock_name_info, rpc_ok, seed, seeded, set, with_db, - PROFILE, + app_with, err_text, mock_blockchain_info, mock_coin, mock_name_info, rpc_ok, seed, seeded, set, + with_db, PROFILE, }; use crate::AppState; @@ -1227,6 +1228,11 @@ use crate::tests::mock_node_rpc::{MockNodeRpc, RpcCall}; /// lock TRANSFER is mined: our TRANSFER coin at 0/0 is the owner coin. /// Returns the lock txid. async fn locked_on_chain(app: &App) -> String { + locked_on_chain_on(app, Network::Regtest).await +} + +/// [`locked_on_chain`] for a profile on `net`. +async fn locked_on_chain_on(app: &App, net: Network) -> String { let draft = build(app).await.expect("lock builds"); let txid = draft.summary["txid"].as_str().unwrap().to_string(); with_db(app, |c| { @@ -1245,7 +1251,7 @@ async fn locked_on_chain(app: &App) -> String { params![NAME], ) .unwrap(); - seed_owner_coin(c, Network::Regtest, &txid, COV_TRANSFER); + seed_owner_coin(c, net, &txid, COV_TRANSFER); }); txid } @@ -1948,3 +1954,713 @@ async fn lock_broadcast_refused_when_the_name_would_expire_during_the_lockup() { .expect("one block earlier the lock is sent"); m[2].assert_async().await; } + +// --- Finalize & sign (T3) --------------------------------------------------- + +use crate::commands::shakedex::{prepare_lock_finalize, PreparedLockFinalize, StepInput}; + +/// Mined at TRANSFER_HEIGHT; on regtest the lockup is over once tip + 1 >= +/// TRANSFER_HEIGHT + 10, i.e. from tip TRANSFER_HEIGHT + 9. +const TRANSFER_HEIGHT: i64 = QUIET_TIP - 100; +const SIGN_MTP: u64 = 1_700_100_000; +const RENEWAL_BLOCK: &str = "aa00000000000000000000000000000000000000000000000000000000000000"; + +fn ready_tip(net: Network) -> i64 { + TRANSFER_HEIGHT + i64::from(net.name_params().transfer_lockup) - 1 +} + +fn price(p: &str) -> Vec { + vec![StepInput { price: p.into() }] +} + +/// The lock TRANSFER coin as `GET /coin` sends it: at our address 0/0, +/// committing to `program` (the lock's, unless a test says otherwise). +fn lock_transfer_coin(net: Network, txid: &str, program: &[u8; 32], height: i64) -> Value { + let nh = hex::encode(crate::noncustodial::names::hash_name(NAME).unwrap()); + json!({ + "hash": txid, "index": 0, "value": NAME_VALUE, "address": addr00(net).0, + "height": height, "coinbase": false, "version": 0, + "covenant": { "type": COV_TRANSFER, "action": "TRANSFER", "items": [ + nh, hex::encode(NAME_HEIGHT.to_le_bytes()), "00", hex::encode(program) ] } + }) +} + +/// `getnameinfo` once the lock TRANSFER is the owner, mined at +/// TRANSFER_HEIGHT. +fn locked_name_info(renewal: u64, lock_txid: &str) -> Value { + let mut v = name_info(renewal, 0, lock_txid); + v["info"]["transfer"] = TRANSFER_HEIGHT.into(); + v +} + +/// `getblockchaininfo` at `tip`, with the median time when given. +fn chain_info(net: Network, tip: i64, mtp: Option) -> Value { + let chain = if net == Network::Main { + "main" + } else { + "regtest" + }; + let mut v = json!({ "chain": chain, "blocks": tip, "headers": tip }); + if let Some(m) = mtp { + v["mediantime"] = m.into(); + } + v +} + +struct Ready { + net: Network, + node: ServerGuard, + mocks: Vec, + app: App, + listing_id: String, + lock_txid: String, +} + +impl Ready { + /// Swap the node's answers: tip and MTP, `getnameinfo`, the lock TRANSFER + /// coin (`None`: hsd's 404). + async fn node(&mut self, tip: i64, mtp: Option, info: Value, coin: Option) { + let chain = chain_info(self.net, tip, mtp); + self.node_with(chain, info, coin).await; + } + + /// [`Ready::node`] with `getblockchaininfo`'s whole reply. + async fn node_with(&mut self, chain: Value, info: Value, coin: Option) { + for m in self.mocks.drain(..) { + m.remove_async().await; + } + for (method, result) in [ + ("getblockchaininfo", chain), + ("getblockhash", json!(RENEWAL_BLOCK)), + ] { + let m = self + .node + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson(json!({ "method": method }))) + .with_header("content-type", "application/json") + .with_body(rpc_ok(result)) + .create_async() + .await; + self.mocks.push(m); + } + self.mocks.push(mock_name_info(&mut self.node, info).await); + let path = format!("/coin/{}/0", self.lock_txid); + self.mocks.push(match coin { + Some(c) => mock_coin(&mut self.node, &self.lock_txid, c).await, + None => { + self.node + .mock("GET", path.as_str()) + .with_status(404) + .create_async() + .await + } + }); + } + + fn key(&self) -> crate::noncustodial::shakedex::lock_key::LockKey { + derive_lock_key(&master(), self.net, 0, NAME).unwrap() + } + + /// The lock TRANSFER coin as the node reports it once mined. + fn coin(&self) -> Value { + lock_transfer_coin( + self.net, + &self.lock_txid, + &self.key().program, + TRANSFER_HEIGHT, + ) + } + + fn info(&self) -> Value { + locked_name_info(RENEWAL, &self.lock_txid) + } + + fn listing(&self) -> ShakedexListing { + with_db(&self.app, |c| { + queries::get_shakedex_listing(c, &self.listing_id) + .unwrap() + .unwrap() + }) + } +} + +/// NAME locked on `network`, its lock TRANSFER mined at TRANSFER_HEIGHT and +/// the owner, the lockup over, the listing ReadyToFinalize; the node answers +/// as hsd would at the first tip the FINALIZE is valid. +async fn ready_fixture(network: &str) -> Ready { + let net = net_of(network); + let (node, mocks, app) = lock_fixture(network, "mnemonic_hot", QUIET_TIP).await; + if net == Network::Main { + // Locking a name on mainnet needs the flag (R15); the tests then + // set it as they need. + with_db(&app, |c| set(c, "shakedex_experimental", "true")); + } + let lock_txid = locked_on_chain_on(&app, net).await; + let listing_id = open_listing(&app).unwrap().id; + with_db(&app, |c| { + c.execute( + "UPDATE shakedex_listings SET state = 'ready_to_finalize' WHERE id = ?1", + [&listing_id], + ) + .unwrap(); + }); + let mut r = Ready { + net, + node, + mocks, + app, + listing_id, + lock_txid, + }; + let (info, coin) = (r.info(), r.coin()); + r.node(ready_tip(net), Some(SIGN_MTP), info, Some(coin)) + .await; + r +} + +async fn prepare( + r: &Ready, + prices: &[StepInput], +) -> Result { + prepare_lock_finalize(&r.app.state(), &r.listing_id, prices, None).await +} + +/// A refusal wrote nothing: the listing is still ReadyToFinalize and only +/// the lock draft exists. +fn assert_nothing_finalized(r: &Ready, drafts: i64) { + let l = r.listing(); + assert_eq!(l.state, ListingState::ReadyToFinalize); + assert_eq!(l.lock_finalize_draft_id, None); + assert_eq!(l.steps_json, "[]"); + assert_eq!(count(&r.app, "wallet_tx_drafts"), drafts); +} + +/// The fixture's node lets Finalize & sign through, and what it built is +/// the FINALIZE of our TRANSFER coin into the lock of the re-derived key. +#[tokio::test] +async fn prepare_lock_finalize_builds_the_finalize_into_the_lock() { + let r = ready_fixture("regtest").await; + let p = prepare(&r, &price("5")).await.expect("ready"); + let key = r.key(); + assert_eq!(p.key.pubkey, key.pubkey); + assert_eq!(p.prices, vec![5_000_000]); + assert_eq!(p.lock_value, NAME_VALUE); + assert_eq!(p.mtp, SIGN_MTP); + assert_eq!(p.lock_time, sell::buy_now_lock_time(SIGN_MTP)); + assert_eq!(p.listing.id, r.listing_id); + assert_eq!(p.payment_address, r.listing().payment_address.unwrap()); + let plan = &p.plan.plan; + assert_eq!(plan.inputs[0].txid, r.lock_txid, "spends the lock TRANSFER"); + assert_eq!(plan.inputs[0].vout, 0); + assert_eq!(plan.outputs[0].address, key.address, "into the lock"); + assert_eq!(plan.outputs[0].value, NAME_VALUE); + assert_eq!( + plan.outputs[0].covenant_type, + crate::noncustodial::sync::COV_FINALIZE + ); + assert_eq!( + plan.outputs[0].covenant_items_hex[6], RENEWAL_BLOCK, + "the renewal block the node reported" + ); + assert_nothing_finalized(&r, 1); +} + +/// R18 at Finalize & sign: the node's confirmed lock TRANSFER must commit to +/// SHA3-256 of `lock_script(derived pub)`. A TRANSFER committing to any +/// other program (another key's lock, another address) is refused before +/// anything is built, asked or written, also when the database's copy of +/// the public key says the other key (the key is re-derived, never read +/// back); so is one the node reports without a readable covenant (could not +/// check). +#[tokio::test] +async fn refuses_finalize_on_commitment_mismatch() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + let other = derive_lock_key(&master(), net, 0, "othername").unwrap(); + let lock_txid = r.lock_txid.clone(); + let other_coin = lock_transfer_coin(net, &lock_txid, &other.program, TRANSFER_HEIGHT); + let info = r.info(); + r.node( + ready_tip(net), + Some(SIGN_MTP), + info.clone(), + Some(other_coin.clone()), + ) + .await; + let err = prepare(&r, &price("5")).await.err().expect("refused"); + assert!(err_text(err).contains(sell::LOCK_COMMITMENT_MISMATCH)); + + // The stored public key says the other key too: still refused. + let stored = r.listing().lock_pubkey_hex; + with_db(&r.app, |c| { + c.execute( + "UPDATE shakedex_listings SET lock_pubkey_hex = ?1 WHERE id = ?2", + params![hex::encode(other.pubkey), r.listing_id], + ) + .unwrap(); + }); + let err = prepare(&r, &price("5")).await.err().expect("refused"); + assert!(err_text(err).contains(sell::LOCK_COMMITMENT_MISMATCH)); + with_db(&r.app, |c| { + c.execute( + "UPDATE shakedex_listings SET lock_pubkey_hex = ?1 WHERE id = ?2", + params![stored, r.listing_id], + ) + .unwrap(); + }); + + let good = r.coin(); + let mut no_cov = good.clone(); + no_cov.as_object_mut().unwrap().remove("covenant"); + let mut short_items = good.clone(); + short_items["covenant"]["items"] + .as_array_mut() + .unwrap() + .truncate(3); + let mut not_transfer = good.clone(); + not_transfer["covenant"]["type"] = crate::noncustodial::sync::COV_UPDATE.into(); + let mut bad_height = good.clone(); + bad_height["covenant"]["items"][1] = json!("zz"); + for (case, coin) in [ + ("no covenant", no_cov), + ("no address items", short_items), + ("not a TRANSFER", not_transfer), + ("unreadable name height", bad_height), + ] { + r.node(ready_tip(net), Some(SIGN_MTP), info.clone(), Some(coin)) + .await; + let err = prepare(&r, &price("5")).await.err().expect(case); + assert!( + matches!(err, crate::error::AppError::Rpc(_)), + "{case}: could not check: {err:?}" + ); + } + assert_nothing_finalized(&r, 1); +} + +/// R19/R18: the name must still be held by our lock TRANSFER (the owner, +/// output 0, of an unrevoked name), and the TRANSFER must be mined and +/// unspent; each missing field is "could not check". +#[tokio::test] +async fn finalize_and_sign_refused_when_the_name_left_its_lock_transfer() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + let lock_txid = r.lock_txid.clone(); + let coin = r.coin(); + let mut moved = locked_name_info(RENEWAL, &"77".repeat(32)); + moved["info"]["transfer"] = 0.into(); + let mut other_output = r.info(); + other_output["info"]["owner"]["index"] = 1.into(); + let mut revoked = r.info(); + revoked["info"]["revoked"] = (QUIET_TIP - 1).into(); + let mut no_owner = r.info(); + no_owner["info"].as_object_mut().unwrap().remove("owner"); + let mut no_index = r.info(); + no_index["info"]["owner"] + .as_object_mut() + .unwrap() + .remove("index"); + let mut no_revoked = r.info(); + no_revoked["info"] + .as_object_mut() + .unwrap() + .remove("revoked"); + let mut no_info = r.info(); + no_info.as_object_mut().unwrap().remove("info"); + let mut no_height = coin.clone(); + no_height.as_object_mut().unwrap().remove("height"); + let mempool = lock_transfer_coin(net, &lock_txid, &r.key().program, -1); + for (case, info, coin, needle) in [ + ( + "owner moved", + moved, + Some(coin.clone()), + "no longer held by its lock transfer", + ), + ( + "another output owns it", + other_output, + Some(coin.clone()), + "no longer held by its lock transfer", + ), + ( + "revoked", + revoked, + Some(coin.clone()), + "no longer held by its lock transfer", + ), + ("no owner", no_owner, Some(coin.clone()), "could not check"), + ( + "no owner index", + no_index, + Some(coin.clone()), + "could not check", + ), + ( + "no revoked", + no_revoked, + Some(coin.clone()), + "could not check", + ), + ("no info", no_info, Some(coin.clone()), "could not check"), + ("coin spent", r.info(), None, "no longer unspent"), + ("in the mempool", r.info(), Some(mempool), "not mined"), + ( + "coin without a height", + r.info(), + Some(no_height), + "could not check", + ), + ] { + r.node(ready_tip(net), Some(SIGN_MTP), info, coin).await; + let e = err_text(prepare(&r, &price("5")).await.err().expect(case)); + assert!(e.contains(needle), "{case}: {e}"); + } + + // The name expired and was registered again: the TRANSFER commits to + // an older name height than the node's. + let mut old_height = r.coin(); + old_height["covenant"]["items"][1] = json!(hex::encode((NAME_HEIGHT - 1).to_le_bytes())); + r.node(ready_tip(net), Some(SIGN_MTP), r.info(), Some(old_height)) + .await; + let e = err_text(prepare(&r, &price("5")).await.err().expect("re-registered")); + assert!(e.contains("registered again"), "{e}"); + + // The wallet's own records must hold the lock TRANSFER as the name's + // coin (a sync behind the chain is refused, not guessed). + r.node(ready_tip(net), Some(SIGN_MTP), r.info(), Some(r.coin())) + .await; + let row = with_db(&r.app, |c| { + c.query_row( + "SELECT owner_txid FROM tracked_name_states WHERE name = ?1", + [NAME], + |row| row.get::<_, String>(0), + ) + .unwrap() + }); + with_db(&r.app, |c| { + c.execute( + "UPDATE tracked_name_states SET owner_txid = ?1 WHERE name = ?2", + params![OWNER_TXID, NAME], + ) + .unwrap(); + }); + let e = err_text(prepare(&r, &price("5")).await.err().expect("wallet behind")); + assert!(e.contains("has not seen the lock transfer"), "{e}"); + // Another TRANSFER coin of ours is the wallet's owner coin. + with_db(&r.app, |c| { + seed_owner_coin(c, net, &"88".repeat(32), COV_TRANSFER); + }); + let e = err_text(prepare(&r, &price("5")).await.err().expect("other coin")); + assert!(e.contains("has not seen the lock transfer"), "{e}"); + with_db(&r.app, |c| { + c.execute( + "UPDATE tracked_name_states SET owner_txid = ?1 WHERE name = ?2", + params![row, NAME], + ) + .unwrap(); + }); + + // The node and the wallet disagree on the coin (value, address). + let mut other_value = r.coin(); + other_value["value"] = (NAME_VALUE + 1).into(); + let mut no_address = r.coin(); + no_address.as_object_mut().unwrap().remove("address"); + for (case, coin) in [("value", other_value), ("address", no_address)] { + r.node(ready_tip(net), Some(SIGN_MTP), r.info(), Some(coin)) + .await; + let e = prepare(&r, &price("5")).await.err().expect(case); + assert!(matches!(e, crate::error::AppError::Rpc(_)), "{case}: {e:?}"); + } + + // The tip the lockup is judged at must come from the node. + let mut no_tip = chain_info(net, ready_tip(net), Some(SIGN_MTP)); + no_tip.as_object_mut().unwrap().remove("blocks"); + r.node_with(no_tip, r.info(), Some(r.coin())).await; + let e = err_text(prepare(&r, &price("5")).await.err().expect("no tip")); + assert!(e.contains("could not check"), "no tip: {e}"); + assert_nothing_finalized(&r, 1); +} + +/// R19: Finalize & sign opens once the transfer lockup is over at the next +/// block (blocks_until_finalize == 0), and a listing still Locking is +/// refused whatever the node says. +#[tokio::test] +async fn finalize_and_sign_refused_before_the_lockup() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + r.node(ready_tip(net) - 1, Some(SIGN_MTP), r.info(), Some(r.coin())) + .await; + let e = err_text( + prepare(&r, &price("5")) + .await + .err() + .expect("one block early"), + ); + assert!(e.contains("lockup ends in 1 block"), "{e}"); + r.node(ready_tip(net), Some(SIGN_MTP), r.info(), Some(r.coin())) + .await; + prepare(&r, &price("5")).await.expect("at the lockup's end"); + + for (state, needle) in [ + ("locking", "after the transfer lockup"), + ("finalizing", "already finalized into its lock"), + ("listed", "already finalized into its lock"), + ] { + with_db(&r.app, |c| { + c.execute( + "UPDATE shakedex_listings SET state = ?1 WHERE id = ?2", + params![state, r.listing_id], + ) + .unwrap(); + }); + let e = err_text(prepare(&r, &price("5")).await.err().expect(state)); + assert!(e.contains(needle), "{state}: {e}"); + } +} + +/// R31 at Finalize & sign, with 0 lockup left: refused while the expiry end +/// is at or below tip + 1 + day (regtest day = 10), built one block earlier. +/// The node's own renewal height decides, read from getnameinfo. +#[tokio::test] +async fn finalize_and_sign_refused_when_the_name_would_expire_first() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + let p = net.name_params(); + // A name close to its expiry: the lock TRANSFER mined a renewal window + // after TRANSFER_HEIGHT, Finalize & sign at the first tip it is valid. + let transfer_height = TRANSFER_HEIGHT + i64::from(p.renewal_window); + let tip = transfer_height + i64::from(p.transfer_lockup) - 1; + let mut coin = r.coin(); + coin["height"] = transfer_height.into(); + let lock_txid = r.lock_txid.clone(); + let info = |renewal: u64| { + let mut v = locked_name_info(renewal, &lock_txid); + v["info"]["transfer"] = transfer_height.into(); + v + }; + // expiry end = renewal + window; refused when end <= tip + 1 + day. + let refused_renewal = + u64::try_from(tip + 1 + i64::from(p.margin_day()) - i64::from(p.renewal_window)).unwrap(); + r.node( + tip, + Some(SIGN_MTP), + info(refused_renewal), + Some(coin.clone()), + ) + .await; + let e = err_text(prepare(&r, &price("5")).await.err().expect("refused")); + assert!(e.contains("too soon to finalize it into the lock"), "{e}"); + r.node( + tip, + Some(SIGN_MTP), + info(refused_renewal + 1), + Some(coin.clone()), + ) + .await; + prepare(&r, &price("5")) + .await + .expect("one block more is enough"); + + for field in ["renewal", "claimed", "height", "renewals", "weak"] { + let mut missing = info(refused_renewal + 1); + missing["info"].as_object_mut().unwrap().remove(field); + r.node(tip, Some(SIGN_MTP), missing, Some(coin.clone())) + .await; + let e = prepare(&r, &price("5")).await.err().expect(field); + assert!( + matches!(e, crate::error::AppError::Rpc(_)), + "{field}: {e:?}" + ); + } + assert_nothing_finalized(&r, 1); +} + +/// R19: prices a person can type are refused in the backend with the +/// reason; a Buy Now takes exactly one. +#[tokio::test] +async fn step_prices_out_of_range_are_refused() { + let r = ready_fixture("regtest").await; + for (text, why) in [ + ("0", "above 0"), + ("0.0009", "dust"), + ("2040000000.000001", "money supply"), + ("1.0000001", "6 decimals"), + ("five", "not a number"), + ] { + let e = err_text(prepare(&r, &price(text)).await.err().expect(text)); + assert!(e.contains(why), "{text}: {e}"); + } + for prices in [ + vec![], + vec![ + StepInput { price: "5".into() }, + StepInput { price: "4".into() }, + ], + ] { + let e = err_text(prepare(&r, &prices).await.err().expect("one price")); + assert!(e.contains("exactly one price"), "{e}"); + } + assert_eq!( + prepare(&r, &price("2040000000")) + .await + .expect("the supply") + .prices, + vec![crate::noncustodial::shakedex::purchase::MAX_MONEY] + ); + assert_nothing_finalized(&r, 1); +} + +/// R15/R29: Finalize & sign acts on an existing listing, so the mainnet +/// experimental flag does not gate it. +#[tokio::test] +async fn finalize_and_sign_ignores_experimental_flag() { + let r = ready_fixture("mainnet").await; + with_db(&r.app, |c| set(c, "shakedex_experimental", "false")); + prepare(&r, &price("5")) + .await + .expect("not gated by the flag"); +} + +/// The Cancel transfer linked to the listing spends the same TRANSFER coin +/// as the FINALIZE: while it may still be mined (unsent, or sent and not +/// given up) Finalize & sign is refused. A dropped, failed or deleted cancel +/// does not hold it back. +#[tokio::test] +async fn finalize_and_sign_refused_while_the_cancel_transfer_is_alive() { + let r = ready_fixture("regtest").await; + let link = |id: &str, status: &str| { + let id = id.to_string(); + with_db(&r.app, |c| { + queries::insert_tx_draft(c, &id, PROFILE, "cancel", "00", "{}", "{}").unwrap(); + c.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = ?2", + params![status, id], + ) + .unwrap(); + c.execute( + "UPDATE shakedex_listings SET abort_draft_id = ?1, abort_txid = ?2 WHERE id = ?3", + params![id, "c0".repeat(32), r.listing_id], + ) + .unwrap(); + }); + id + }; + for status in ["draft", "signed", "broadcast_pending", "broadcasted"] { + link(&format!("cancel-{status}"), status); + let e = err_text(prepare(&r, &price("5")).await.err().expect(status)); + assert!(e.contains(sell::CANCEL_TRANSFER_PENDING), "{status}: {e}"); + } + for status in ["dropped", "failed"] { + link(&format!("cancel-{status}"), status); + prepare(&r, &price("5")) + .await + .unwrap_or_else(|e| panic!("{status}: {e:?}")); + } + let id = link("cancel-deleted", "draft"); + with_db(&r.app, |c| queries::delete_tx_draft(c, &id).unwrap()); + assert_eq!(r.listing().abort_draft_id, None, "deleted: unlinked"); + prepare(&r, &price("5")).await.expect("deleted cancel"); +} + +/// R16/R6: the Shakedex gates, and the unlocked signer. +#[tokio::test] +async fn finalize_and_sign_refused_for_ledger_and_watch_only() { + let r = ready_fixture("regtest").await; + for kind in ["ledger_hardware", "xpriv_hot"] { + with_db(&r.app, |c| { + c.execute( + "UPDATE wallet_profiles SET kind = ?1 WHERE id = ?2", + params![kind, PROFILE], + ) + .unwrap(); + }); + let e = err_text(prepare(&r, &price("5")).await.err().expect(kind)); + assert!( + e.contains(crate::noncustodial::shakedex::RECOVERY_PHRASE_ONLY), + "{kind}: {e}" + ); + } +} + +#[tokio::test] +async fn finalize_and_sign_refused_without_write_capability() { + let r = ready_fixture("regtest").await; + with_db(&r.app, |c| set(c, "chain_source", "explorer")); + let e = err_text(prepare(&r, &price("5")).await.err().expect("refused")); + assert!(e.contains(crate::commands::shakedex::NEEDS_SENDING_NODE)); +} + +#[tokio::test] +async fn finalize_and_sign_needs_the_unlocked_signer() { + let r = ready_fixture("regtest").await; + *r.app.state::().signer.lock().unwrap() = None; + let e = prepare(&r, &price("5")).await.err().expect("locked"); + assert!(matches!(e, crate::error::AppError::WalletLocked), "{e:?}"); + for m in &r.mocks { + assert!( + !m.matched_async().await, + "nothing is read before the signer" + ); + } +} + +/// A listing of another profile is not found, whatever its id. +#[tokio::test] +async fn finalize_and_sign_acts_only_on_the_active_profiles_listing() { + let r = ready_fixture("regtest").await; + let e = prepare_lock_finalize(&r.app.state(), "no-such-listing", &price("5"), None) + .await + .err() + .expect("unknown"); + assert!(matches!(e, crate::error::AppError::NotFound(_)), "{e:?}"); + with_db(&r.app, |c| { + queries::insert_wallet_profile( + c, + "other", + "Other", + "mnemonic_hot", + "regtest", + "xpubX", + 0, + false, + ) + .unwrap(); + c.execute( + "UPDATE shakedex_listings SET wallet_profile_id = 'other' WHERE id = ?1", + [&r.listing_id], + ) + .unwrap(); + }); + let e = prepare(&r, &price("5")).await.err().expect("not ours"); + assert!(matches!(e, crate::error::AppError::NotFound(_)), "{e:?}"); +} + +/// R19: the Buy Now lock time comes from the node's median time; without it +/// nothing is signed. +#[tokio::test] +async fn finalize_and_sign_refused_without_the_median_time() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + r.node(ready_tip(net), None, r.info(), Some(r.coin())).await; + let e = prepare(&r, &price("5")).await.err().expect("refused"); + assert!( + matches!(&e, crate::error::AppError::Rpc(m) if m.contains("median time")), + "{e:?}" + ); +} + +/// Deviation 6: a reverse auction's schedule arrives with T8. +#[tokio::test] +async fn finalize_and_sign_refused_for_a_reverse_auction_until_t8() { + let r = ready_fixture("regtest").await; + with_db(&r.app, |c| { + c.execute( + "UPDATE shakedex_listings SET mode = 'reverse_auction' WHERE id = ?1", + [&r.listing_id], + ) + .unwrap(); + }); + let e = err_text(prepare(&r, &price("5")).await.err().expect("refused")); + assert!(e.contains("reverse auctions are not supported yet"), "{e}"); +} From 5944196cbca69b7385b58ea61f30dfa6b9504af2 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 15:55:31 +0200 Subject: [PATCH 07/17] feat(shakedex): Finalize & sign the price in one unlock, and export the listing file --- SECURITY.md | 10 +- docs/CODING_STANDARDS.md | 2 +- docs/specs/2026-10-05-shakedex-name-sales.md | 12 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/secure_confirm.rs | 34 ++ src-tauri/src/commands/secure_prompt.rs | 18 +- src-tauri/src/commands/shakedex.rs | 249 ++++++++++- src-tauri/src/db/queries.rs | 9 +- src-tauri/src/lib.rs | 2 + src-tauri/src/noncustodial/shakedex/sell.rs | 67 ++- .../src/tests/shakedex_layering_tests.rs | 7 +- src-tauri/src/tests/shakedex_sell_tests.rs | 405 +++++++++++++++++- src/types/index.ts | 24 ++ 13 files changed, 815 insertions(+), 25 deletions(-) create mode 100644 src-tauri/src/commands/secure_confirm.rs diff --git a/SECURITY.md b/SECURITY.md index bf2262e..fb67ca5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -243,12 +243,16 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market #### 3. Signing an input the wallet does not own -**Mitigation:** The seller's lock coin is a foreign input carrying its own witness; the wallet signs only its own inputs. A Ledger signs every input as the wallet's own P2WPKH, so a plan with a foreign input, a custom sequence or a lock time is refused twice: when the draft is signed (`commands::tx`) and in the Ledger signer itself (`providers::ledger::signing`), which sees only the plan's shape. Draft signing refuses every Shakedex draft for a Ledger by its action as a class (`shakedex::is_shakedex_action`), because the FINALIZE into a lock is an ordinary plan the device signer cannot tell from any other; a plan with a lock-key or foreign input is refused by its shape. A lock coin of ours is spent only by a cancel, signed with the lock key, which the signer derives from the seed at that moment and never stores. Locking a name (day 0) derives the name's lock key from the unlocked seed as well, only to read its public key and run the R18 self-check, whose test signature is over a template with a zero outpoint and is checked and dropped; only the public key is stored on the listing, and the TRANSFER commits to the program of the key just checked. The signer refuses an input that is both foreign and lock-key (`sign_plan_tx`), and refuses a lock-key input unless it is a `0x83` (`ANYONECANPAY|SINGLE`) input with a final sequence in a plan with lock time 0, from a receive-branch path at an unhardened index, whose output at the same index is a TRANSFER at that key's lock address with exactly 4 items, of this name (item 0 is the name hash), to a version-0 address (item 2 is `00`) whose hash (item 3) is our own receive address re-derived from the input's path (`noncustodial/shakedex/cancel.rs::check_lock_key_input`). +**Mitigation:** The seller's lock coin is a foreign input carrying its own witness; the wallet signs only its own inputs. A Ledger signs every input as the wallet's own P2WPKH, so a plan with a foreign input, a custom sequence or a lock time is refused twice: when the draft is signed (`commands::tx`) and in the Ledger signer itself (`providers::ledger::signing`), which sees only the plan's shape. Draft signing refuses every Shakedex draft for a Ledger by its action as a class (`shakedex::is_shakedex_action`), because the FINALIZE into a lock is an ordinary plan the device signer cannot tell from any other; a plan with a lock-key or foreign input is refused by its shape. The wallet itself spends a lock coin of ours only by a cancel, signed with the lock key, which the signer derives from the seed at that moment and never stores; a buyer spends it with a price step the lock key signed at Finalize & sign (5). Locking a name (day 0) derives the name's lock key from the unlocked seed as well, only to read its public key and run the R18 self-check, whose test signature is over a template with a zero outpoint and is checked and dropped; only the public key is stored on the listing, and the TRANSFER commits to the program of the key just checked. The signer refuses an input that is both foreign and lock-key (`sign_plan_tx`), and refuses a lock-key input unless it is a `0x83` (`ANYONECANPAY|SINGLE`) input with a final sequence in a plan with lock time 0, from a receive-branch path at an unhardened index, whose output at the same index is a TRANSFER at that key's lock address with exactly 4 items, of this name (item 0 is the name hash), to a version-0 address (item 2 is `00`) whose hash (item 3) is our own receive address re-derived from the input's path (`noncustodial/shakedex/cancel.rs::check_lock_key_input`). #### 4. The daemon and purchases **Guarantee:** The background daemon refreshes each purchase's state from the chain but never rebroadcasts one. A purchase that went missing is rebroadcast at most once, and only by the app's own sync, through the same broadcast gates as every other send. +#### 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 (`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 at signing minus 512 seconds, 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. + --- ## Mitigations reference table @@ -266,8 +270,10 @@ The Market lists names for sale through Shakedex, read from the LearnHNS Market | Market redirect or host swap | HTTPS LearnHNS host only, no redirects, override debug-only | `market/learnhns.rs` | `learnhns_tests` | | Tampered listing or price | Verified on the profile's node; price re-checked before broadcast | `noncustodial/shakedex/verify.rs` | `shakedex_verify_tests`, `shakedex_cmd_tests` | | Ledger signs a foreign input, a lock coin or a Shakedex draft | Draft signing refuses every Shakedex draft by action and a lock-key or foreign plan by shape; the device signer refuses foreign, lock-key and custom-sequence or lock-time plans by shape only, and cannot see the action | `commands/tx.rs`, `providers/ledger/signing.rs` | `ledger_plan_guard_tests` | -| Lock key signs anything but a cancel | Signer accepts only a `0x83` input whose same-index output is a TRANSFER of the name at its lock address to our own version-0 receive address, and refuses an input that is both foreign and lock-key | `noncustodial/shakedex/cancel.rs` (`check_lock_key_input`), `noncustodial/actions.rs` (`sign_plan_tx`) | `actions::tests::sign_plan_refuses_a_lock_key_input_that_is_not_a_cancel` | +| Lock key signs anything but a price step or a cancel | Signer accepts only a `0x83` input whose same-index output is a TRANSFER of the name at its lock address to our own version-0 receive address, and refuses an input that is both foreign and lock-key; price steps (`0x84`) are signed only by Finalize & sign after the secure-window confirmation, over the lock coin its own FINALIZE creates, paying the listing's payment address, each verified before it is kept | `noncustodial/shakedex/cancel.rs` (`check_lock_key_input`), `noncustodial/actions.rs` (`sign_plan_tx`), `commands/shakedex.rs` (`finalize_and_sign_confirmed`), `noncustodial/shakedex/sell.rs` (`sign_step`, `lock_output`) | `actions::tests::sign_plan_refuses_a_lock_key_input_that_is_not_a_cancel`, `shakedex_sell_tests::{lock_then_finalize_and_sign, refuses_finalize_on_commitment_mismatch, confirmation_lists_every_step, cancelled_confirmation_writes_nothing}` | | Name locked to a key that cannot move it | The lock key is re-derived and self-checked (script, program, address, a test `0x84` signature) before the TRANSFER draft is written; the TRANSFER commits to that key's program | `noncustodial/shakedex/sell.rs` (`lock_self_check`, `lock_transfer_covenant`), `commands/shakedex.rs` (`build_lock_draft_inner`) | `shakedex_sell_tests::{refuses_lock_on_self_check_failure, lock_draft_commits_to_the_derived_lock_address}`, `shakedex_vector_tests::lock_transfer_covenant_matches_hsd` | +| Name finalized into a lock this wallet cannot move | Finalize & sign re-derives the lock key after the node reads and refuses unless the node's confirmed lock TRANSFER commits to that key's lock and the stored public key is that key's (R18) | `commands/shakedex.rs` (`prepare_lock_finalize`) | `shakedex_sell_tests::refuses_finalize_on_commitment_mismatch` | +| Listing file out before its FINALIZE is mined | Export refuses in Locking, ReadyToFinalize and Finalizing | `commands/shakedex.rs` (`export_listing_file_from_conn`) | `shakedex_sell_tests::listing_file_is_exported_only_once_the_finalize_is_mined` | | Daemon rebroadcasts a purchase | `SyncCaller::Daemon` never rebroadcasts | `commands/sync.rs`, `shakedex_jobs.rs` | `shakedex_purchase_state_tests::{daemon_sync_makes_no_send_call_where_the_apps_sync_does, daemon_never_rebroadcasts_and_leaves_it_to_the_app}` | --- diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md index 5bfb01f..8e152d7 100644 --- a/docs/CODING_STANDARDS.md +++ b/docs/CODING_STANDARDS.md @@ -29,7 +29,7 @@ Locally `cargo test` is enough; CI uses nextest for its two-lane split (see Existing imports of `commands::read` (profile resolution, node readiness), `commands::sync::open_conn`, `commands::namebase` and `commands::secure_prompt` are legacy shared layers — do not add to them. -- Where the shared pieces live instead: a background job opens its connection with `db::connection::open_migrated` (the body behind the legacy `open_conn`); the context a draft-building command starts from (`Ctx`, `load_ctx`, `fee_rate`, `fetch_name_state`, `renewal_block`) is `commands::draft_ctx`; send policy that needs only a node client (`broadcast_network_guard_with_client`) is `noncustodial::rpc`. `tests/shakedex_layering_tests.rs` reads the Shakedex sources and fails on a sibling-command import; add a new module's source to its list. +- Where the shared pieces live instead: a background job opens its connection with `db::connection::open_migrated` (the body behind the legacy `open_conn`); the context a draft-building command starts from (`Ctx`, `load_ctx`, `fee_rate`, `fetch_name_state`, `renewal_block`) is `commands::draft_ctx`; send policy that needs only a node client (`broadcast_network_guard_with_client`) is `noncustodial::rpc`; a secure-window confirmation of rows (`confirm_rows`, on the legacy `secure_prompt`) is `commands::secure_confirm`. `tests/shakedex_layering_tests.rs` reads the Shakedex sources and fails on a sibling-command import; add a new module's source to its list. - **Pure logic is split from IO** so it can be unit-tested without a live node or a Tauri `State`: the `*_with_client(&dyn NodeRpc, ...)` pattern for RPC code, `*_from_conn(&Connection)` for DB code, `*_pure.rs` modules for diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index a58370e..e4d8a01 100644 --- a/docs/specs/2026-10-05-shakedex-name-sales.md +++ b/docs/specs/2026-10-05-shakedex-name-sales.md @@ -114,12 +114,12 @@ 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 at signing minus 512 s, so it is valid at once by R3; it is taken when the steps are signed, not when the name was locked. -*Enforced:* `commands/shakedex.rs::{shakedex_build_lock_draft, shakedex_finalize_and_sign}`, `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`), `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. It only reads the node, and a read error, a missing field 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}`, `noncustodial::shakedex::sell` unit test `buy_now_lock_time_is_valid_at_the_next_block`, `deadlines_cmd_tests::listing_ready_repeats`, `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`. +*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`), `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. It only reads the node, and a read error, a missing field 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}`, `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`, `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`. **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 `prompt_secure`. -*Pinned:* `shakedex_sell_tests::confirmation_lists_every_step`. +*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`); nothing is signed or written unless it is confirmed. +*Pinned:* `shakedex_sell_tests::{confirmation_lists_every_step, cancelled_confirmation_writes_nothing}`, `noncustodial::shakedex::sell` unit test `finalize_and_sign_rows_list_fee_steps_and_permanence`. **R21 — Proceeds go to a fresh address, reserved.** Each listing pays a receive address that is marked used when the listing is created, so the next listing gets another one; after a restore it is found only within the restore window of `address_gap_limit` addresses (window extension is a known gap, §5). The cancel destination is reserved the same way (a buyer's name destination already is, R13). *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`). @@ -130,8 +130,8 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI *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`). **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`, `noncustodial/shakedex/listing_file.rs::write_listing_file`. -*Pinned:* `learnhns_tests::{no_http_off_mainnet, upload_shape, pending_listing_shape, refresh_status_shape}`, `shakedex_jobs_tests::{publishes_pending_on_day_zero, publishes_active_after_one_confirmation, nothing_published_when_the_box_is_off, expires_at_refreshed_before_it_lapses}`, `shakedex_listing_file_tests::written_listing_file_round_trips_through_the_strict_parser`. +*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` (refused while the listing is Locking, ReadyToFinalize or Finalizing: the file leaves the wallet only once its FINALIZE into the lock is mined), `noncustodial/shakedex/listing_file.rs::write_listing_file` (read back by the strict parser `ListingFile::parse` before it is returned). +*Pinned:* `learnhns_tests::{no_http_off_mainnet, upload_shape, pending_listing_shape, refresh_status_shape}`, `shakedex_jobs_tests::{publishes_pending_on_day_zero, publishes_active_after_one_confirmation, nothing_published_when_the_box_is_off, expires_at_refreshed_before_it_lapses}`, `shakedex_listing_file_tests::written_listing_file_round_trips_through_the_strict_parser`, `shakedex_sell_tests::listing_file_is_exported_only_once_the_finalize_is_mined`. **R24 — Reverse auctions on a market that sells one price.** All steps (2 to 1000, at least 15 minutes apart) are signed at once and the listing file carries them all. LearnHNS gets the current step (R3) as a single-step upload, replaced as the schedule moves. A future step leaking early is harmless because of R3's lock time and sequence. Out-of-range schedules are refused in the backend, with the reason: fewer than 2 or more than 1000 steps, steps closer than 15 minutes, prices not strictly falling, a floor of 0 or above the start. *Enforced:* `noncustodial/shakedex/sell.rs::reverse_schedule`, `shakedex_jobs.rs::advance_reverse_auctions_step`. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1633bdc..8392b1f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod node_readiness; pub mod paid_swaps; pub mod read; pub mod read_pure; +pub mod secure_confirm; pub mod secure_prompt; pub mod secure_wallet; pub mod settings; diff --git a/src-tauri/src/commands/secure_confirm.rs b/src-tauri/src/commands/secure_confirm.rs new file mode 100644 index 0000000..5f5e06f --- /dev/null +++ b/src-tauri/src/commands/secure_confirm.rs @@ -0,0 +1,34 @@ +//! Asking the secure window to confirm rows, for command modules that may +//! not name the legacy `secure_prompt` layer themselves (CODING_STANDARDS +//! §Rust layering: a small dedicated helper module). The window shows the +//! rows verbatim, out of reach of the main webview. + +use tauri::{AppHandle, Runtime}; + +use crate::commands::secure_prompt::{prompt_secure, SecurePromptRequest}; +use crate::error::AppError; + +/// Show `details` (`{ "rows": [...] }`) under `title` and `message` in a +/// `confirm` prompt; `UserRejected` unless the user confirms. +pub(crate) async fn confirm_rows( + app: &AppHandle, + title: &str, + message: &str, + details: serde_json::Value, +) -> Result<(), AppError> { + let answer = prompt_secure( + app, + SecurePromptRequest { + mode: "confirm".into(), + title: title.into(), + message: message.into(), + details: Some(details), + ..Default::default() + }, + ) + .await?; + if !answer.confirmed { + return Err(AppError::UserRejected); + } + Ok(()) +} diff --git a/src-tauri/src/commands/secure_prompt.rs b/src-tauri/src/commands/secure_prompt.rs index f1b0238..3679fff 100644 --- a/src-tauri/src/commands/secure_prompt.rs +++ b/src-tauri/src/commands/secure_prompt.rs @@ -107,6 +107,21 @@ pub(crate) fn push_test_answer(result: SecurePromptResult) { TEST_ANSWERS.with(|q| q.borrow_mut().push_back(result)); } +// Test-only record of every request `prompt_secure` answered from the +// queue, so a test can read the rows the window would have shown. +#[cfg(test)] +thread_local! { + static TEST_REQUESTS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +/// Drain the requests `prompt_secure` answered from the test queue, oldest +/// first. +#[cfg(test)] +pub(crate) fn take_test_requests() -> Vec { + TEST_REQUESTS.with(|r| std::mem::take(&mut *r.borrow_mut())) +} + /// Open a secure window for `request` and block until the user answers (or /// closes the window, which resolves to a non-confirmed result). /// @@ -122,7 +137,8 @@ pub async fn prompt_secure( #[cfg(test)] { if let Some(answer) = TEST_ANSWERS.with(|q| q.borrow_mut().pop_front()) { - let _ = (app, &request); + let _ = app; + TEST_REQUESTS.with(|r| r.borrow_mut().push(request)); return Ok(answer); } } diff --git a/src-tauri/src/commands/shakedex.rs b/src-tauri/src/commands/shakedex.rs index 801b2e1..8049b4a 100644 --- a/src-tauri/src/commands/shakedex.rs +++ b/src-tauri/src/commands/shakedex.rs @@ -25,7 +25,9 @@ use crate::noncustodial::network::Network; use crate::noncustodial::rpc::{self, ChainSource, NodeRpcClient}; use crate::noncustodial::send::{self, SpendableCoin, DUST_THRESHOLD}; use crate::noncustodial::session::session_ttl_ms; -use crate::noncustodial::shakedex::listing_file::{ListingFile, MAX_LISTING_FILE_BYTES}; +use crate::noncustodial::shakedex::listing_file::{ + write_listing_file, ListingFile, NewListingFile, PriceStep, MAX_LISTING_FILE_BYTES, +}; use crate::noncustodial::shakedex::lock_key::{derive_lock_key, LockKey}; use crate::noncustodial::shakedex::purchase::{ self, FinalizeInput, MarketFee, PurchaseFinalizeSummary, PurchaseInput, PurchaseSummary, @@ -676,15 +678,13 @@ pub struct StepInput { } /// Everything Finalize & sign has checked and built before it asks (R20). -#[cfg_attr(not(test), allow(dead_code))] pub(crate) struct PreparedLockFinalize { pub(crate) ctx: Ctx, pub(crate) listing: ShakedexListing, pub(crate) key: LockKey, - /// The FINALIZE into the lock; the lock coin is `(plan.txid, 0)`. + /// The FINALIZE into the lock; the lock coin is its FINALIZE output + /// (`sell::lock_output`). pub(crate) plan: actions::PlanResult, - /// The value of the lock coin (the name's coin, carried by output 0). - pub(crate) lock_value: u64, pub(crate) prices: Vec, /// R19: the Buy Now lock time, from the MTP at signing. pub(crate) lock_time: u64, @@ -704,7 +704,6 @@ fn could_not_check(what: &str, name: &str) -> AppError { /// key (after the reads) and the FINALIZE plan. Writes nothing and asks /// nothing; a refusal leaves the listing ReadyToFinalize. Every node field /// it reads is matched on its own: a missing one is "could not check". -#[cfg_attr(not(test), allow(dead_code))] pub(crate) async fn prepare_lock_finalize( state: &State<'_, AppState>, listing_id: &str, @@ -911,7 +910,6 @@ pub(crate) async fn prepare_lock_finalize( listing, key, plan, - lock_value, prices, lock_time: sell::buy_now_lock_time(mtp), mtp, @@ -919,6 +917,215 @@ pub(crate) async fn prepare_lock_finalize( }) } +/// R20: the Finalize & sign confirmation's own title, never the +/// transaction prompt's. +pub const FINALIZE_AND_SIGN_TITLE: &str = "Confirm Finalize & sign"; +const FINALIZE_AND_SIGN_MESSAGE: &str = "Review these details. This signs the finalize of \ + the name into its lock and every price below with the lock key."; + +/// A listing as the UI reads it (T7's "My listings"). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListingSummary { + pub id: String, + pub name: String, + pub mode: ListingMode, + pub state: ListingState, + pub lock_txid: Option, + pub lock_vout: Option, + pub payment_address: Option, + pub steps: Vec, + pub expires_at: Option, + /// The FINALIZE into the lock, signed; `broadcast_tx_draft` sends it. + pub finalize_draft_id: Option, +} + +impl ListingSummary { + fn of(l: &ShakedexListing) -> Result { + let steps = serde_json::from_str(&l.steps_json) + .map_err(|e| AppError::Other(format!("corrupted listing: unreadable steps: {e}")))?; + Ok(Self { + id: l.id.clone(), + name: l.name.clone(), + mode: l.mode, + state: l.state, + lock_txid: l.lock_txid.clone(), + lock_vout: l.lock_vout, + payment_address: l.payment_address.clone(), + steps, + expires_at: l.expires_at, + finalize_draft_id: l.lock_finalize_draft_id.clone(), + }) + } +} + +/// R19 day 2, generic over the runtime so tests drive it: check +/// ([`prepare_lock_finalize`]), confirm (R20), then in one hold of the +/// unlocked session sign the FINALIZE and every step over the lock coin it +/// creates, write the listing file, and store the signed FINALIZE draft with +/// the listing — now Finalizing — in one database transaction. Sends +/// nothing: `commands::tx::broadcast_tx_draft` sends the draft, and the +/// steps leave the wallet only once that FINALIZE is mined +/// ([`export_listing_file_from_conn`]). +pub(crate) async fn finalize_and_sign_confirmed( + state: &State<'_, AppState>, + app: &tauri::AppHandle, + listing_id: &str, + prices: &[StepInput], + fee_rate: Option, +) -> Result { + let p = prepare_lock_finalize(state, listing_id, prices, fee_rate).await?; + let net = p.ctx.network; + let (lock_vout, lock_value) = sell::lock_output(&p.plan.plan, &p.key.address)?; + let steps: Vec<(u64, u64)> = p.prices.iter().map(|&price| (price, p.lock_time)).collect(); + crate::commands::secure_confirm::confirm_rows( + app, + FINALIZE_AND_SIGN_TITLE, + FINALIZE_AND_SIGN_MESSAGE, + sell::finalize_and_sign_rows(&sell::FinalizeAndSignRows { + name: &p.listing.name, + finalize_fee: p.plan.fee, + lock_address: &p.key.address, + payment_address: &p.payment_address, + steps: &steps, + mtp: p.mtp, + }), + ) + .await?; + + let mut lock_txid = [0u8; 32]; + hex::decode_to_slice(&p.plan.txid, &mut lock_txid) + .map_err(|e| AppError::Other(format!("FINALIZE txid is not hex: {e}")))?; + let payment = output_address_from_string(net, &p.payment_address)?; + // One unlock (R19): the session is authorized once for the FINALIZE and + // every step; the confirmation may have outlasted the earlier check. + let (signed_hex, signed) = { + let mut slot = state + .signer + .lock() + .map_err(|e| AppError::Lock(e.to_string()))?; + let session = slot.as_mut().ok_or(AppError::WalletLocked)?; + session.authorize(&p.ctx.profile_id, session_ttl_ms(&p.ctx.settings))?; + let (hex, txid) = actions::sign_plan(session, &p.plan.plan)?; + if txid != p.plan.txid { + return Err(AppError::Other( + "the signed FINALIZE's txid is not the plan's".into(), + )); + } + let signed = steps + .iter() + .map(|&(price, lock_time)| { + sell::sign_step( + &p.key, + &template::StepTemplate { + lock_outpoint: (lock_txid, lock_vout), + lock_value, + lock_pubkey: &p.key.pubkey, + payment: payment.clone(), + price, + lock_time_secs: lock_time, + }, + ) + .map(|signature| PriceStep { + price, + lock_time, + signature, + fee: 0, + }) + }) + .collect::, _>>()?; + (hex, signed) + }; + let expires_at = p.mtp + sell::LISTING_LIFETIME_SECS; + let file = write_listing_file( + &NewListingFile { + name: &p.listing.name, + lock_txid, + lock_vout, + public_key: p.key.pubkey, + payment_addr: &p.payment_address, + steps: &signed, + expires_at, + }, + net, + )?; + let stored: Vec = signed + .iter() + .map(|s| sell::StoredStep { + price: s.price, + lock_time: s.lock_time, + signature: hex::encode(s.signature), + }) + .collect(); + + let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; + // The signed FINALIZE and the Finalizing listing commit together: the + // listing leaves CANCEL_ABORTABLE in the same transaction, so the + // before-lock job never reads its FINALIZE as an abort. + let tx = conn.unchecked_transaction()?; + let draft_id = draft_ctx::persist_in_tx( + &tx, + &p.ctx.profile_id, + &draft_ctx::DraftLabel { + action: sell::LOCK_FINALIZE_ACTION, + name: &p.listing.name, + recipient: Some(&p.key.address), + name_list: None, + warnings: &[sell::STEP_SIGNATURE_PERMANENCE.to_string()], + }, + &p.plan, + )?; + let summary = queries::get_tx_draft(&tx, &draft_id)? + .ok_or_else(|| AppError::Other("draft vanished after insert".into()))? + .summary_json; + queries::update_tx_draft_signed(&tx, &draft_id, &signed_hex, &summary)?; + let n = queries::mark_listing_finalizing_in_tx( + &tx, + &p.listing.id, + &queries::FinalizingListing { + finalize_draft_id: &draft_id, + lock_txid: &p.plan.txid, + lock_vout, + steps_json: &serde_json::to_string(&stored)?, + listing_file_json: &file, + expires_at, + }, + )?; + if n != 1 { + return Err(AppError::InvalidInput( + "this listing changed meanwhile: nothing was saved; try again".into(), + )); + } + tx.commit()?; + let l = queries::get_shakedex_listing(&conn, &p.listing.id)? + .ok_or_else(|| AppError::Other("listing vanished after Finalize & sign".into()))?; + ListingSummary::of(&l) +} + +/// R23: the saved listing file of one of the profile's listings, once its +/// FINALIZE into the lock is mined (Listed or later): before that its steps +/// are over a coin that may never exist. +pub(crate) fn export_listing_file_from_conn( + conn: &rusqlite::Connection, + profile_id: &str, + listing_id: &str, +) -> Result { + let l = queries::get_shakedex_listing(conn, listing_id)? + .filter(|l| l.wallet_profile_id == profile_id) + .ok_or_else(|| AppError::NotFound(format!("listing {listing_id}")))?; + if matches!( + l.state, + ListingState::Locking | ListingState::ReadyToFinalize | ListingState::Finalizing + ) { + return Err(AppError::InvalidInput( + "the listing file can be exported once the finalize into the lock is mined".into(), + )); + } + l.listing_file_json.ok_or_else(|| { + AppError::InvalidInput("this lock has no listing file here: import its saved file".into()) + }) +} + // --- commands --------------------------------------------------------------- /// One page of LearnHNS Market listings, each verified on the profile's node. @@ -1466,6 +1673,34 @@ pub async fn shakedex_build_lock_draft( ) } +/// Finalize & sign, day 2 (R19, R20): see [`finalize_and_sign_confirmed`]. +/// Returns the listing, now Finalizing; its `finalizeDraftId` is the signed +/// FINALIZE, which `broadcast_tx_draft` sends. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub async fn shakedex_finalize_and_sign( + state: State<'_, AppState>, + app: tauri::AppHandle, + listing_id: String, + prices: Vec, + fee_rate: Option, +) -> Result { + finalize_and_sign_confirmed(&state, &app, &listing_id, &prices, fee_rate).await +} + +/// R23: the saved listing file of one of the active profile's listings; see +/// [`export_listing_file_from_conn`]. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub async fn shakedex_export_listing_file( + state: State<'_, AppState>, + listing_id: String, +) -> Result { + let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; + let profile = draft_ctx::active_profile(&conn)?; + export_listing_file_from_conn(&conn, &profile.id, &listing_id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index c42ade8..925abac 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1930,8 +1930,10 @@ fn revert_to_ready_set() -> String { /// What Finalize & sign writes on its listing (R19, R23). pub struct FinalizingListing<'a> { pub finalize_draft_id: &'a str, - /// The FINALIZE's txid; the lock coin is its output 0. + /// The FINALIZE's txid and the index of its FINALIZE output: the lock + /// coin. pub lock_txid: &'a str, + pub lock_vout: u32, pub steps_json: &'a str, pub listing_file_json: &'a str, pub expires_at: u64, @@ -1951,7 +1953,7 @@ pub fn mark_listing_finalizing_in_tx( .map_err(|_| AppError::Other("listing expiry out of range".into()))?; Ok(tx.execute( "UPDATE shakedex_listings - SET state = ?2, lock_finalize_draft_id = ?3, lock_txid = ?4, lock_vout = 0, + SET state = ?2, lock_finalize_draft_id = ?3, lock_txid = ?4, lock_vout = ?9, steps_json = ?5, listing_file_json = ?6, expires_at = ?7, updated_at = datetime('now') WHERE id = ?1 AND state = ?8", @@ -1963,7 +1965,8 @@ pub fn mark_listing_finalizing_in_tx( f.steps_json, f.listing_file_json, expires_at, - ListingState::ReadyToFinalize + ListingState::ReadyToFinalize, + i64::from(f.lock_vout) ], )?) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ee96970..2e0a062 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -702,6 +702,8 @@ pub fn run() { commands::shakedex::shakedex_build_purchase_draft, commands::shakedex::shakedex_build_purchase_finalize_draft, commands::shakedex::shakedex_build_lock_draft, + commands::shakedex::shakedex_finalize_and_sign, + commands::shakedex::shakedex_export_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/sell.rs b/src-tauri/src/noncustodial/shakedex/sell.rs index ad11769..72b364e 100644 --- a/src-tauri/src/noncustodial/shakedex/sell.rs +++ b/src-tauri/src/noncustodial/shakedex/sell.rs @@ -3,7 +3,7 @@ //! Now lock time (R19) and the FINALIZE into the lock. use crate::error::AppError; -use crate::noncustodial::actions::PlanResult; +use crate::noncustodial::actions::{DraftPlan, PlanResult}; use crate::noncustodial::covenants; use crate::noncustodial::names; use crate::noncustodial::network::{NameParams, Network}; @@ -15,6 +15,7 @@ use crate::noncustodial::shakedex::script; use crate::noncustodial::shakedex::template::{ secs_until_valid, valid_from_mtp, verify_step_signature, StepTemplate, }; +use crate::noncustodial::sync::COV_FINALIZE; use crate::noncustodial::tx::{Covenant, OutputAddress}; use crate::noncustodial::types::doos_to_hns_string; @@ -250,6 +251,28 @@ pub fn build_lock_finalize_plan(i: &LockFinalizeInput) -> Result Result<(u32, u64), AppError> { + let mut found = plan + .outputs + .iter() + .enumerate() + .filter(|(_, o)| o.covenant_type == COV_FINALIZE && o.address == lock_address); + match (found.next(), found.next()) { + (Some((i, o)), None) => { + let i = u32::try_from(i) + .map_err(|_| AppError::Other("FINALIZE output index out of range".into()))?; + Ok((i, o.value)) + } + _ => Err(AppError::Other( + "the FINALIZE plan does not pay exactly one FINALIZE into the lock".into(), + )), + } +} + /// R23: a listing file's `expiresAt` is the MTP at signing plus 365 days. pub const LISTING_LIFETIME_SECS: u64 = 365 * 86_400; @@ -394,7 +417,6 @@ mod tests { use crate::noncustodial::shakedex::template::{ encode_lock_time, is_valid_at, low_s_signature, verify_step_signature, }; - use crate::noncustodial::sync::COV_FINALIZE; use crate::noncustodial::tx::{sighash, Transaction}; fn key(name: &str) -> LockKey { @@ -547,6 +569,47 @@ mod tests { assert_eq!(res.input_total, out + res.fee); } + /// The lock coin is the plan's one FINALIZE output to the lock address, + /// wherever it sits: its index and value are read from the plan. + #[test] + fn lock_output_is_the_plans_finalize_into_the_lock() { + let k = key("dexreviews"); + let transfer = coin(0x31, 1_000_000, 7); + let funding = [coin(1, 2_000_000, 8)]; + let res = build_lock_finalize_plan(&LockFinalizeInput { + network: Network::Main, + account: 0, + transfer: &transfer, + lock_pubkey: k.pubkey, + name: "dexreviews", + name_height: 120, + weak: false, + claimed: 0, + renewals: 0, + renewal_block: [0x77; 32], + funding: &funding, + change_address: CHANGE, + rate: 7, + fixed_fee: None, + }) + .unwrap(); + assert_eq!(lock_output(&res.plan, &k.address).unwrap(), (0, 1_000_000)); + let mut swapped = res.plan.clone(); + swapped.outputs.swap(0, 1); + assert_eq!(lock_output(&swapped, &k.address).unwrap(), (1, 1_000_000)); + let other = key("othername"); + assert!( + lock_output(&res.plan, &other.address).is_err(), + "another lock" + ); + let mut none = res.plan.clone(); + none.outputs[0].covenant_type = 0; + assert!(lock_output(&none, &k.address).is_err(), "no FINALIZE"); + let mut two = res.plan.clone(); + two.outputs.push(two.outputs[0].clone()); + assert!(lock_output(&two, &k.address).is_err(), "two FINALIZEs"); + } + /// R19: a price is HNS with at most 6 decimals, at least the dust limit /// and at most the money supply; every refusal says why. #[test] diff --git a/src-tauri/src/tests/shakedex_layering_tests.rs b/src-tauri/src/tests/shakedex_layering_tests.rs index 6854a00..1892a7a 100644 --- a/src-tauri/src/tests/shakedex_layering_tests.rs +++ b/src-tauri/src/tests/shakedex_layering_tests.rs @@ -18,11 +18,12 @@ const SOURCES: [(&str, &str); 3] = [ ]; /// The only command modules these sources may name: the shared draft -/// context. Everything else under `commands` is a sibling, and the legacy +/// context, and the secure window's confirm helper (`secure_confirm`, the +/// one place that names `secure_prompt` for them). Everything else under `commands` is a sibling, and the legacy /// shared layers (`sync`, `read`, `namebase`, `secure_prompt`) are siblings /// too — refusing the whole class keeps a new sibling from slipping past a /// list of known ones. -const ALLOWED: [&str; 1] = ["draft_ctx"]; +const ALLOWED: [&str; 2] = ["draft_ctx", "secure_confirm"]; /// Code only: a mention in a comment is not an import, and the unit tests at /// the end of a file (`mod tests`, where `super` is the file itself) are not @@ -82,6 +83,8 @@ fn sibling_check_catches_every_sibling_form() { } for line in [ "use crate::commands::draft_ctx::{self, Ctx};", + "use crate::commands::secure_confirm;", + "crate::commands::secure_confirm::confirm_rows(app, t, m, d)", "draft_ctx::fee_rate(&p.ctx, fee_rate)", "use crate::noncustodial::shakedex::purchase;", ] { diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index acce430..8e3592c 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -384,6 +384,7 @@ fn finalizing(conn: &Connection, id: &str, draft_id: &str) -> usize { &queries::FinalizingListing { finalize_draft_id: draft_id, lock_txid: &"f1".repeat(32), + lock_vout: 0, steps_json: r#"[{"price":5000000,"lockTime":1,"signature":"ab"}]"#, listing_file_json: "{}", expires_at: 1_731_536_000, @@ -2144,7 +2145,10 @@ async fn prepare_lock_finalize_builds_the_finalize_into_the_lock() { let key = r.key(); assert_eq!(p.key.pubkey, key.pubkey); assert_eq!(p.prices, vec![5_000_000]); - assert_eq!(p.lock_value, NAME_VALUE); + assert_eq!( + sell::lock_output(&p.plan.plan, &key.address).unwrap(), + (0, NAME_VALUE) + ); assert_eq!(p.mtp, SIGN_MTP); assert_eq!(p.lock_time, sell::buy_now_lock_time(SIGN_MTP)); assert_eq!(p.listing.id, r.listing_id); @@ -2664,3 +2668,402 @@ async fn finalize_and_sign_refused_for_a_reverse_auction_until_t8() { let e = err_text(prepare(&r, &price("5")).await.err().expect("refused")); assert!(e.contains("reverse auctions are not supported yet"), "{e}"); } + +// --- Finalize & sign: confirm, sign, store; export (T3) --------------------- + +use crate::commands::secure_prompt::{push_test_answer, take_test_requests, SecurePromptResult}; +use crate::commands::shakedex::{ + export_listing_file_from_conn, finalize_and_sign_confirmed, ListingSummary, +}; +use crate::noncustodial::shakedex::listing_file::ListingFile; +use crate::noncustodial::shakedex::template::{ + encode_lock_time, is_valid_at, secs_until_valid, verify_step_signature, StepTemplate, +}; +use crate::noncustodial::sync::COV_FINALIZE; + +/// Clear the request record and queue the answer to the next prompt. +fn answer(confirmed: bool) { + let _ = take_test_requests(); + push_test_answer(SecurePromptResult { + value: None, + confirmed, + }); +} + +async fn finalize_and_sign( + r: &Ready, + prices: &[StepInput], +) -> Result { + finalize_and_sign_confirmed(&r.app.state(), r.app.handle(), &r.listing_id, prices, None).await +} + +/// A node mock that fails the test if anything is sent. +async fn no_broadcast(r: &mut Ready) -> Mock { + r.node + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson( + json!({ "method": "sendrawtransaction" }), + )) + .with_header("content-type", "application/json") + .with_body(rpc_ok(json!("00"))) + .expect(0) + .create_async() + .await +} + +/// R19 day 2 end to end: one confirmation, then the FINALIZE into the lock +/// is a signed draft spending our lock TRANSFER into a FINALIZE at the lock +/// address, the listing is Finalizing with the lock outpoint (the +/// FINALIZE's txid and the index of its FINALIZE output), and its one step +/// and its listing file are signed over that outpoint, with that output's +/// value, by the lock key, and verify. Nothing is sent. +#[tokio::test] +async fn lock_then_finalize_and_sign() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + let key = derive_lock_key(&master(), net, 0, NAME).unwrap(); + let sent = no_broadcast(&mut r).await; + answer(true); + let s = finalize_and_sign(&r, &price("5")) + .await + .expect("finalize & sign"); + assert_eq!(s.state, ListingState::Finalizing); + assert_eq!( + (s.id.as_str(), s.name.as_str()), + (r.listing_id.as_str(), NAME) + ); + + let draft_id = s.finalize_draft_id.clone().expect("the FINALIZE draft"); + let row = with_db(&r.app, |c| { + queries::get_tx_draft(c, &draft_id).unwrap().unwrap() + }); + assert_eq!(row.action, sell::LOCK_FINALIZE_ACTION); + assert_eq!(row.status, "signed", "sent next by broadcast_tx_draft"); + assert!(row.signed_tx_hex.is_some()); + sent.assert_async().await; + let plan: DraftPlan = serde_json::from_str(&row.signing_inputs_json).unwrap(); + assert_eq!( + (plan.inputs[0].txid.as_str(), plan.inputs[0].vout), + (r.lock_txid.as_str(), 0) + ); + let lock_outputs: Vec = plan + .outputs + .iter() + .enumerate() + .filter(|(_, o)| o.covenant_type == COV_FINALIZE) + .map(|(i, _)| i) + .collect(); + assert_eq!(lock_outputs.len(), 1, "one FINALIZE output"); + let lock_vout = lock_outputs[0]; + let lock_out = &plan.outputs[lock_vout]; + assert_eq!(lock_out.address, key.address); + assert_eq!(lock_out.value, NAME_VALUE); + let finalize_txid: String = serde_json::from_str::(&row.summary_json).unwrap()["txid"] + .as_str() + .unwrap() + .into(); + + let l = r.listing(); + assert_eq!(l.lock_finalize_draft_id.as_deref(), Some(draft_id.as_str())); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(finalize_txid.as_str()), Some(lock_vout as i64)), + "the lock outpoint is the FINALIZE output of the plan" + ); + assert_eq!( + (s.lock_txid.clone(), s.lock_vout), + (l.lock_txid.clone(), l.lock_vout) + ); + assert_eq!(s.steps.len(), 1); + let step = &s.steps[0]; + assert_eq!(step.price, 5_000_000); + let mut outpoint = [0u8; 32]; + hex::decode_to_slice(&finalize_txid, &mut outpoint).unwrap(); + let sig: [u8; 65] = hex::decode(&step.signature).unwrap().try_into().unwrap(); + let pubkey: [u8; 33] = hex::decode(&l.lock_pubkey_hex).unwrap().try_into().unwrap(); + assert_eq!(pubkey, key.pubkey, "the stored pubkey is the lock key's"); + verify_step_signature( + &StepTemplate { + lock_outpoint: (outpoint, lock_vout as u32), + lock_value: lock_out.value, + lock_pubkey: &pubkey, + payment: crate::noncustodial::tx::output_address_from_string( + net, + l.payment_address.as_deref().unwrap(), + ) + .unwrap(), + price: 5_000_000, + lock_time_secs: step.lock_time, + }, + &sig, + ) + .expect("the step verifies over the FINALIZE's lock outpoint"); + + let file = + ListingFile::parse(l.listing_file_json.as_deref().expect("file saved"), net).unwrap(); + assert_eq!( + (file.lock_txid, file.lock_vout), + (outpoint, lock_vout as u32) + ); + assert_eq!(file.public_key, key.pubkey); + assert_eq!(file.payment_addr, l.payment_address.clone().unwrap()); + assert_eq!(file.steps.len(), 1); + assert_eq!(file.steps[0].signature, sig); + assert_eq!( + (file.steps[0].price, file.steps[0].lock_time), + (step.price, step.lock_time) + ); + assert_eq!( + file.expires_at, + Some(SIGN_MTP + sell::LISTING_LIFETIME_SECS) + ); + assert_eq!( + l.expires_at, + Some((SIGN_MTP + sell::LISTING_LIFETIME_SECS) as i64) + ); + assert_eq!(s.expires_at, l.expires_at); + let stored: Vec = serde_json::from_str(&l.steps_json).unwrap(); + assert_eq!(stored, s.steps); + + // Once is enough: the listing is no longer ReadyToFinalize. + answer(true); + let e = err_text( + finalize_and_sign(&r, &price("5")) + .await + .expect_err("second run"), + ); + assert!(e.contains("already finalized into its lock"), "{e}"); +} + +/// R19: a Buy Now is valid in the next block at the MTP it was signed at, +/// also at an MTP on a 512-second boundary, where a lock time equal to the +/// MTP would not be. +#[tokio::test] +async fn buy_now_valid_immediately() { + let r = ready_fixture("regtest").await; + answer(true); + let s = finalize_and_sign(&r, &price("5")).await.unwrap(); + let lt = s.steps[0].lock_time; + assert!(is_valid_at(encode_lock_time(lt).unwrap(), SIGN_MTP)); + assert_eq!(secs_until_valid(lt, SIGN_MTP), 0); + + let mut r = ready_fixture("regtest").await; + let aligned = SIGN_MTP / 512 * 512; + assert!(!is_valid_at(encode_lock_time(aligned).unwrap(), aligned)); + let (info, coin) = (r.info(), r.coin()); + r.node(ready_tip(r.net), Some(aligned), info, Some(coin)) + .await; + answer(true); + let s = finalize_and_sign(&r, &price("5")).await.unwrap(); + let lt = s.steps[0].lock_time; + assert!(is_valid_at(encode_lock_time(lt).unwrap(), aligned)); + assert_eq!(secs_until_valid(lt, aligned), 0); +} + +/// R19 (review focus 2): the lock time is the MTP at signing minus 512 s, +/// not anything from the day the name was locked; the app may have been +/// closed through the lockup. The listing's expiry counts from that same +/// MTP. +#[tokio::test] +async fn buy_now_lock_time_is_taken_at_signing() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + let days_later = SIGN_MTP + 9 * 86_400; + let (info, coin) = (r.info(), r.coin()); + r.node(ready_tip(net) + 1_000, Some(days_later), info, Some(coin)) + .await; + answer(true); + let s = finalize_and_sign(&r, &price("5")).await.unwrap(); + assert_eq!(s.steps[0].lock_time, days_later - 512); + assert_eq!(s.steps[0].lock_time, sell::buy_now_lock_time(days_later)); + assert_eq!( + s.expires_at, + Some((days_later + sell::LISTING_LIFETIME_SECS) as i64) + ); +} + +/// R19: without the node's median time nothing is asked, signed or written. +#[tokio::test] +async fn finalize_and_sign_without_the_median_time_asks_and_writes_nothing() { + let mut r = ready_fixture("regtest").await; + let net = Network::Regtest; + let (info, coin) = (r.info(), r.coin()); + r.node(ready_tip(net), None, info, Some(coin)).await; + answer(true); + let e = finalize_and_sign(&r, &price("5")) + .await + .expect_err("refused"); + assert!( + matches!(&e, crate::error::AppError::Rpc(m) if m.contains("median time")), + "{e:?}" + ); + assert!(take_test_requests().is_empty(), "nothing asked"); + assert_nothing_finalized(&r, 1); +} + +/// R20: the confirmation is its own prompt, not the transaction one, and +/// lists the FINALIZE's fee, every step with its price and when it is +/// valid, and the permanence warning — before anything is signed. +#[tokio::test] +async fn confirmation_lists_every_step() { + let r = ready_fixture("regtest").await; + answer(true); + let s = finalize_and_sign(&r, &price("5")).await.unwrap(); + let reqs = take_test_requests(); + assert_eq!( + reqs.len(), + 1, + "one confirmation for the FINALIZE and every step" + ); + let req = &reqs[0]; + assert_eq!(req.mode, "confirm"); + assert_eq!( + req.title, + crate::commands::shakedex::FINALIZE_AND_SIGN_TITLE + ); + assert_ne!(req.title, "Confirm transaction"); + let rows = req.details.as_ref().unwrap()["rows"] + .as_array() + .unwrap() + .clone(); + let value = |label: &str| { + rows.iter() + .find(|r| r["label"] == label) + .map(|r| r["value"].as_str().unwrap().to_string()) + }; + let fee = with_db(&r.app, |c| { + let d = queries::get_tx_draft(c, s.finalize_draft_id.as_deref().unwrap()) + .unwrap() + .unwrap(); + serde_json::from_str::(&d.summary_json).unwrap()["feeDoos"] + .as_i64() + .unwrap() + }); + assert!(fee > 0); + assert_eq!( + value("Network fee (finalize into the lock)"), + Some(crate::noncustodial::types::doos_to_hns_string(fee)) + ); + assert_eq!( + value("Price step 1").as_deref(), + Some("5.000000 HNS, valid at once") + ); + assert_eq!( + value("Paid to"), + r.listing().payment_address, + "the reserved payment address" + ); + assert_eq!(value("Lock address"), Some(r.key().address)); + assert_eq!( + value("Warning").as_deref(), + Some(sell::STEP_SIGNATURE_PERMANENCE) + ); +} + +/// R20: cancelling the confirmation signs and writes nothing. +#[tokio::test] +async fn cancelled_confirmation_writes_nothing() { + let r = ready_fixture("regtest").await; + answer(false); + let e = finalize_and_sign(&r, &price("5")) + .await + .expect_err("rejected"); + assert!(matches!(e, crate::error::AppError::UserRejected), "{e:?}"); + assert_eq!(take_test_requests().len(), 1, "it was asked"); + let l = r.listing(); + assert_eq!(l.state, ListingState::ReadyToFinalize); + assert_eq!((l.steps_json.as_str(), l.listing_file_json), ("[]", None)); + assert_eq!((l.lock_txid, l.lock_vout, l.expires_at), (None, None, None)); + assert_eq!(count(&r.app, "wallet_tx_drafts"), 1, "only the lock draft"); +} + +/// R19, coordinator (a): the FINALIZE draft and the Finalizing listing +/// commit together. When the listing cannot move (it changed while the +/// prompt was open), the draft is rolled back with it. +#[tokio::test] +async fn finalize_and_sign_writes_all_or_nothing() { + let r = ready_fixture("regtest").await; + // The listing leaves ReadyToFinalize as soon as the FINALIZE draft is + // inserted, inside the same transaction. + with_db(&r.app, |c| { + c.execute_batch( + "CREATE TRIGGER move_away AFTER INSERT ON wallet_tx_drafts + BEGIN UPDATE shakedex_listings SET state = 'locking'; END;", + ) + .unwrap(); + }); + answer(true); + let e = err_text( + finalize_and_sign(&r, &price("5")) + .await + .expect_err("refused"), + ); + assert!(e.contains("this listing changed meanwhile"), "{e}"); + with_db(&r.app, |c| { + c.execute_batch("DROP TRIGGER move_away").unwrap(); + }); + assert_nothing_finalized(&r, 1); + assert_eq!(r.listing().listing_file_json, None); +} + +/// R27: from Finalizing on, Cancel transfer is no longer the abort and is +/// refused. +#[tokio::test] +async fn cancel_transfer_refused_once_finalize_and_sign_ran() { + let r = ready_fixture("regtest").await; + answer(true); + finalize_and_sign(&r, &price("5")).await.unwrap(); + let e = err_text( + build_cancel_draft(r.app.state(), NAME.into(), None) + .await + .expect_err("refused"), + ); + assert!( + e.contains(crate::noncustodial::shakedex::NAME_LOCKED_FOR_SALE), + "{e}" + ); +} + +/// R23, deviation 3: the saved file leaves the wallet only once its +/// FINALIZE is mined (Listed and after); before that it would carry steps +/// over a coin that may never exist. +#[tokio::test] +async fn listing_file_is_exported_only_once_the_finalize_is_mined() { + let r = ready_fixture("regtest").await; + let export = || { + with_db(&r.app, |c| { + export_listing_file_from_conn(c, PROFILE, &r.listing_id) + }) + }; + let set_state = |s: ListingState| { + with_db(&r.app, |c| { + c.execute( + "UPDATE shakedex_listings SET state = ?1 WHERE id = ?2", + params![s.as_str(), r.listing_id], + ) + .unwrap(); + }) + }; + let refused = |what: &str| { + let e = err_text(export().expect_err(what)); + assert!( + e.contains("once the finalize into the lock is mined"), + "{what}: {e}" + ); + }; + refused("ReadyToFinalize"); + answer(true); + finalize_and_sign(&r, &price("5")).await.unwrap(); + refused("Finalizing"); + set_state(ListingState::Locking); + refused("Locking"); + set_state(ListingState::Listed); + let file = export().expect("Listed"); + let saved = r.listing().listing_file_json; + assert_eq!(Some(file), saved); + let e = with_db(&r.app, |c| { + export_listing_file_from_conn(c, "another-profile", &r.listing_id) + }) + .expect_err("not ours"); + assert!(matches!(e, crate::error::AppError::NotFound(_)), "{e:?}"); +} diff --git a/src/types/index.ts b/src/types/index.ts index 2a13977..ef0814d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -878,6 +878,30 @@ export interface MarketPage { pageCount: number; } +/** One signed price step of our listing (`sell::StoredStep`). */ +export interface ShakedexStoredStep { + price: number; + lockTime: number; + /** 65 bytes, hex: the low-S signature and the 0x84 sighash byte. */ + signature: string; +} + +/** One of our listings (`commands/shakedex.rs::ListingSummary`). */ +export interface ShakedexListingSummary { + id: string; + name: string; + mode: "buyNow" | "reverseAuction"; + /** `ListingState` in camelCase ("locking", "readyToFinalize", "finalizing", "listed", ...). */ + state: string; + lockTxid: string | null; + lockVout: number | null; + paymentAddress: string | null; + steps: ShakedexStoredStep[]; + expiresAt: number | null; + /** The signed FINALIZE into the lock; `broadcast_tx_draft` sends it. */ + finalizeDraftId: string | null; +} + export type ImportSource = { kind: "file"; path: string } | { kind: "text"; json: string } | { kind: "link"; url: string }; From 28872290a5a57413bf83b0e48b19cfa3096ce919 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 16:13:21 +0200 Subject: [PATCH 08/17] feat(shakedex): follow a listing from the lockup to its mined FINALIZE --- docs/specs/2026-10-05-shakedex-name-sales.md | 4 +- src-tauri/src/commands/sync.rs | 7 + src-tauri/src/shakedex_jobs.rs | 286 ++++++- src-tauri/src/tests/shakedex_sell_tests.rs | 753 ++++++++++++++++++- 4 files changed, 1018 insertions(+), 32 deletions(-) diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index e4d8a01..e78da6e 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 at signing minus 512 s, so it is valid at once by R3; it is taken when the steps are signed, 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`), `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. It only reads the node, and a read error, a missing field 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}`, `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`, `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`. +*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`), `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 but no longer the owner (back in the mempool). Before aborting, the owner coin is looked up: 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. `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, while the lock TRANSFER is a coin again, returns it to ReadyToFinalize without its lock outpoint, steps and file. The two jobs never take the same listing (Locking, ReadyToFinalize and Aborted listings against Finalizing and Listed ones), 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 or lock coin without its address, covenant or 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, sync_lists_the_listing_in_the_app_and_the_daemon}`, `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`, `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`. **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`); nothing is signed or written unless it is confirmed. diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index 138a3fe..ba44364 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -556,6 +556,13 @@ pub async fn run_sync_steps( // 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; } } diff --git a/src-tauri/src/shakedex_jobs.rs b/src-tauri/src/shakedex_jobs.rs index 806da43..b743075 100644 --- a/src-tauri/src/shakedex_jobs.rs +++ b/src-tauri/src/shakedex_jobs.rs @@ -37,17 +37,27 @@ //! 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 ([`refresh_listings_before_lock_with_client`]). It only reads the -//! node. +//! 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 +//! ([`refresh_lock_finalize_with_client`]). Both only read the node, and +//! never take the same listing. use crate::db::queries::{self, PurchaseProgress, PurchaseState, ShakedexPurchase, TxDraftRow}; use crate::error::AppError; +use crate::noncustodial::derivation; use crate::noncustodial::network::Network; use crate::noncustodial::node_rpc::NodeRpc; use crate::noncustodial::rpc::{self, NodeRpcClient}; use crate::noncustodial::send::RESERVATION_TTL_SECS; use crate::noncustodial::shakedex::purchase::{self, transfer_commits_to}; +use crate::noncustodial::shakedex::script; use crate::noncustodial::shakedex::verify; +use crate::noncustodial::sync::COV_FINALIZE; use crate::noncustodial::tx_evidence; /// Blocks a sent purchase may be absent from the node's mempool and chain @@ -838,14 +848,19 @@ enum LockOnChain { /// hsd reports no live state for the name (`info: null`): it never /// existed or has expired. NoName, - /// The lock TRANSFER is a coin (mined or in the mempool), or the owner - /// of a name that is not revoked. - Held, - /// Neither: the owner is another outpoint, or the name is revoked + /// The lock TRANSFER is the owner of a name that is not revoked. + /// `transfer` is hsd's `info.transfer`, the block of the TRANSFER; + /// `None` when the reply leaves it out or says 0 (no TRANSFER, which + /// cannot be while our TRANSFER owns the name: not hsd's whole answer). + Owner { transfer: Option }, + /// The lock TRANSFER is a coin but not the owner yet: it is in the + /// mempool (hsd moves `owner` only when a block is connected). + Pending, + /// 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, + Gone { owner: (String, u32), revoked: bool }, } /// Read [`LockOnChain`]. Any read error, and a reply without `info`, the @@ -862,21 +877,86 @@ async fn lock_on_chain( }; 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()); + let index = owner + .and_then(|o| o.get("index")) + .and_then(|i| i.as_u64()) + .and_then(|i| u32::try_from(i).ok()); let revoked = info.get("revoked").and_then(|r| r.as_u64()); let (Some(hash), Some(index), Some(revoked)) = (hash, index, revoked) else { return Err(AppError::Rpc( "node did not report the name's owner or whether it was revoked".into(), )); }; - let lock_owns = revoked == 0 && index == 0 && hash.eq_ignore_ascii_case(lock_transfer_txid); - if lock_owns || client.get_coin(lock_transfer_txid, 0).await?.is_some() { - Ok(LockOnChain::Held) + let transfer = info + .get("transfer") + .and_then(|t| t.as_u64()) + .and_then(|t| i64::try_from(t).ok()) + .filter(|t| *t > 0); + if revoked == 0 && index == 0 && hash.eq_ignore_ascii_case(lock_transfer_txid) { + Ok(LockOnChain::Owner { transfer }) + } else if client.get_coin(lock_transfer_txid, 0).await?.is_some() { + Ok(LockOnChain::Pending) } else { - Ok(LockOnChain::Gone) + Ok(LockOnChain::Gone { + owner: (hash.to_string(), index), + revoked: revoked != 0, + }) + } +} + +/// Where a name's owner coin sits, from hsd's `GET /coin`. +#[derive(Debug, PartialEq, Eq)] +enum InOurLock { + /// A FINALIZE 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. + Other, + /// Elsewhere, or a coin hsd answers 404 for. + No, +} + +/// Where the name's owner coin `owner` sits: at this listing's lock address +/// (as a FINALIZE, or another covenant) or elsewhere. A coin hsd answers 404 +/// for is [`InOurLock::No`] (T2's abort stands; the trace of a lock coin +/// spent since is T4's). 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, + l: &queries::ShakedexListing, + owner: &(String, u32), +) -> Result { + let Some(coin) = client.get_coin(&owner.0, owner.1).await? else { + return Ok(InOurLock::No); + }; + let (Some(address), Some(covenant)) = (coin.address.as_deref(), coin.covenant.as_ref()) 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 { + Ok(InOurLock::Finalize) + } else { + Ok(InOurLock::Other) } } +/// The lock address of listing `l`, from its stored lock public key. +fn listing_lock_address( + network: Network, + l: &queries::ShakedexListing, +) -> 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) +} + /// 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 @@ -912,7 +992,14 @@ pub async fn refresh_listings_before_lock_step(db_path: &str, profile_id: &str) /// or deleted) → Aborted — a Cancel transfer sent from anywhere, a REVOKE, /// a replaced cancel whose older one was mined, a lock TRANSFER that never /// landed; an Aborted listing whose lock TRANSFER is a coin or the owner -/// again → Locking. +/// 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 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 but not the owner (back in the mempool) → +/// Locking. /// /// Locking again is refused while another listing of the name is open /// ([`queries::unabort_shakedex_listing`]). Anything the node does not @@ -923,17 +1010,31 @@ pub async fn refresh_listings_before_lock_with_client( client: &dyn NodeRpc, profile_id: &str, ) -> Result<(), AppError> { - for l in queries::list_shakedex_listings_before_lock(conn, profile_id, ABORT_RECHECK_DAYS)? { - if let Err(e) = refresh_before_lock(conn, client, &l).await { + let listings = + queries::list_shakedex_listings_before_lock(conn, profile_id, ABORT_RECHECK_DAYS)?; + if listings.is_empty() { + return Ok(()); + } + let network = 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(()) } +/// The network of profile `profile_id`. +fn profile_network(conn: &rusqlite::Connection, profile_id: &str) -> Result { + let profile = queries::get_wallet_profile(conn, profile_id)? + .ok_or_else(|| AppError::NotFound(format!("wallet profile {profile_id}")))?; + derivation::network_from_profile(&profile.network) +} + async fn refresh_before_lock( conn: &rusqlite::Connection, client: &dyn NodeRpc, + network: Network, l: &queries::ShakedexListing, ) -> Result<(), AppError> { let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() else { @@ -961,13 +1062,48 @@ async fn refresh_before_lock( LockOnChain::NoName if abortable => { queries::expire_shakedex_listing(conn, &l.id)?; } - LockOnChain::Gone if abortable => { + LockOnChain::Gone { owner, revoked } if abortable => { + // Coordinator (b): a name finalized into our own lock is not an + // abort, whoever sent the FINALIZE; only positive evidence (the + // owner coin a FINALIZE at this listing's lock address) says so. + if !revoked { + match owner_in_our_lock(client, network, l, &owner).await? { + InOurLock::Finalize => { + queries::adopt_lock_finalized_elsewhere(conn, &l.id, &owner.0, owner.1)?; + return Ok(()); + } + // At our lock but not a FINALIZE: the name went through + // our lock; T4 decides. + InOurLock::Other => return Ok(()), + InOurLock::No => {} + } + } let status = queries::lock_draft_status(conn, l)?; if !status.is_some_and(|s| queries::lock_draft_may_still_land(&s)) { queries::abort_shakedex_listing(conn, &l.id)?; } } - LockOnChain::Held if aborted => relock(conn, l)?, + LockOnChain::Owner { .. } | LockOnChain::Pending if aborted => relock(conn, l)?, + LockOnChain::Owner { + transfer: Some(height), + } if abortable => { + // hsd judges the FINALIZE at tip + 1 (`chain.js`, + // `height < ns.transfer + transferLockup`). + let tip = client.get_blockchain_info().await?.blocks; + let ready = network.name_params().blocks_until_finalize(height, tip) == 0; + match (l.state, ready) { + (queries::ListingState::Locking, true) => { + queries::mark_listing_ready(conn, &l.id)?; + } + (queries::ListingState::ReadyToFinalize, false) => { + queries::mark_listing_locking_again(conn, &l.id)?; + } + _ => {} + } + } + LockOnChain::Pending if l.state == queries::ListingState::ReadyToFinalize => { + queries::mark_listing_locking_again(conn, &l.id)?; + } _ => {} } Ok(()) @@ -983,3 +1119,119 @@ fn relock(conn: &rusqlite::Connection, l: &queries::ShakedexListing) -> Result<( } Ok(()) } + +// --------------------------------------------------------------------------- +// Listings after the FINALIZE into the lock is built (R19) +// --------------------------------------------------------------------------- + +/// 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) { + 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_lock_finalize_with_client(&conn, &client, profile_id).await { + eprintln!("shakedex listings: finalize 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, while the lock TRANSFER `(lock_transfer_txid, 0)` is +/// a coin again → ReadyToFinalize, its lock outpoint, steps and file +/// dropped (they were signed over a coin that does not exist); +/// - 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( + 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() { + return Ok(()); + } + let network = profile_network(conn, profile_id)?; + for l in listings { + if let Err(e) = refresh_lock_finalize(conn, client, network, &l).await { + eprintln!("shakedex listings: {} ({}): {e}", l.id, l.name); + } + } + Ok(()) +} + +async fn refresh_lock_finalize( + 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 { + 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_never_landed(conn, client, l).await? + { + queries::revert_listing_to_ready(conn, &l.id)?; + } + return Ok(()); + }; + let (Some(address), Some(covenant)) = (coin.address.as_deref(), 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}" + ))); + }; + if address != listing_lock_address(network, l)? || covenant.kind != COV_FINALIZE { + return Err(AppError::Other(format!( + "lock coin {lock_txid}:{lock_vout} is not a FINALIZE at the listing's lock address" + ))); + } + if coin.mined_height()?.is_some() { + queries::mark_listing_listed(conn, &l.id)?; + } else { + queries::mark_listing_finalizing_again(conn, &l.id)?; + } + Ok(()) +} + +/// Positive evidence that a Finalizing listing's FINALIZE never landed: its +/// draft can no longer be mined (`failed`, `dropped`, or gone) and the lock +/// TRANSFER it spends is a coin again (hsd's `GET /coin` answers 404 for a +/// coin spent in a block or in the mempool). +async fn finalize_never_landed( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + l: &queries::ShakedexListing, +) -> Result { + let status = match l.lock_finalize_draft_id.as_deref() { + Some(id) => queries::get_tx_draft(conn, id)?.map(|d| d.status), + None => None, + }; + let dead = + status.is_none_or(|s| !(queries::never_sent(&s) || queries::may_have_reached_chain(&s))); + let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() else { + return Ok(false); + }; + Ok(dead && client.get_coin(lock_transfer_txid, 0).await?.is_some()) +} diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index 8e3592c..a46689e 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -635,7 +635,7 @@ use crate::noncustodial::network::Network; use crate::noncustodial::session::SignerSession; use crate::noncustodial::shakedex::lock_key::derive_lock_key; use crate::noncustodial::shakedex::sell::{self, LOCK_ACTION, LOCK_COSTS}; -use crate::noncustodial::sync::{COV_REGISTER, COV_REVEAL, COV_TRANSFER}; +use crate::noncustodial::sync::{COV_REGISTER, COV_REVEAL, COV_TRANSFER, COV_UPDATE}; use crate::noncustodial::types::TxSummary; use crate::tests::shakedex_cmd_tests::{ app_with, err_text, mock_blockchain_info, mock_coin, mock_name_info, rpc_ok, seed, seeded, set, @@ -1629,16 +1629,30 @@ fn info_with(owner_txid: &str, owner_index: u32, revoked: u64) -> Value { } /// A node answering `info` for NAME, and `GET /coin` with a mined coin for -/// each outpoint in `coins` and hsd's empty 404 for any other. -fn facts(info: Value, coins: &[(&str, u32)]) -> MockNodeRpc { - let coins: Vec<(String, u32)> = coins.iter().map(|(t, v)| (t.to_string(), *v)).collect(); +/// each outpoint in `coins` and hsd's empty 404 for any other. Each coin is +/// what hsd sends: at our address 0/0 (the owner coin before the lock, the +/// lock TRANSFER, a Cancel transfer's UPDATE all sit there) with its +/// covenant type. +fn facts(info: Value, coins: &[(&str, u32, u8)]) -> MockNodeRpc { + let coins: Vec<(String, u32, u8)> = coins + .iter() + .map(|(t, v, c)| (t.to_string(), *v, *c)) + .collect(); MockNodeRpc::new() .with_name_info(info) .with_get_coin(move |txid, vout| { Ok(coins .iter() - .any(|(t, v)| t == txid && *v == vout) - .then(|| node_coin(txid, vout, Some(QUIET_TIP - 100)))) + .find(|(t, v, _)| t == txid && *v == vout) + .map(|(_, _, cov)| { + coin_at( + txid, + vout, + &addr00(Network::Regtest).0, + *cov, + QUIET_TIP - 100, + ) + })) }) } @@ -1665,7 +1679,10 @@ async fn external_cancel_aborts_the_listing() { run_abort_job( &app, - &facts(info_with(&lock_txid, 0, 0), &[(&lock_txid, 0)]), + &facts( + info_with(&lock_txid, 0, 0), + &[(&lock_txid, 0, COV_TRANSFER)], + ), ) .await; assert_eq!(listing_state(&app, &id), ListingState::Locking); @@ -1673,7 +1690,10 @@ async fn external_cancel_aborts_the_listing() { let other_cancel = "ee".repeat(32); run_abort_job( &app, - &facts(info_with(&other_cancel, 0, 0), &[(&other_cancel, 0)]), + &facts( + info_with(&other_cancel, 0, 0), + &[(&other_cancel, 0, COV_UPDATE)], + ), ) .await; assert_eq!(listing_state(&app, &id), ListingState::Aborted); @@ -1713,7 +1733,10 @@ async fn dead_lock_draft_aborts_and_the_name_can_be_locked_again() { }); run_abort_job( &app, - &facts(info_with(OWNER_TXID, 0, 0), &[(OWNER_TXID, 0)]), + &facts( + info_with(OWNER_TXID, 0, 0), + &[(OWNER_TXID, 0, COV_REGISTER)], + ), ) .await; assert_eq!(listing_state(&app, &l.id), ListingState::Aborted, "{end}"); @@ -1742,7 +1765,11 @@ 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(&app, &facts(info_with(&a_txid, 0, 0), &[(&a_txid, 0)])).await; + run_abort_job( + &app, + &facts(info_with(&a_txid, 0, 0), &[(&a_txid, 0, COV_UPDATE)]), + ) + .await; assert_eq!(listing_state(&app, &l.id), ListingState::Aborted); } @@ -1761,7 +1788,10 @@ async fn lock_draft_in_flight_without_its_coin_changes_nothing() { } run_abort_job( &app, - &facts(info_with(OWNER_TXID, 0, 0), &[(OWNER_TXID, 0)]), + &facts( + info_with(OWNER_TXID, 0, 0), + &[(OWNER_TXID, 0, COV_REGISTER)], + ), ) .await; assert_eq!(listing_state(&app, &id), ListingState::Locking, "{status}"); @@ -1775,7 +1805,10 @@ async fn name_without_live_state_expires_the_listing() { 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; - let gone = facts(json!({ "info": null, "start": null }), &[(&lock_txid, 0)]); + let gone = facts( + json!({ "info": null, "start": null }), + &[(&lock_txid, 0, COV_TRANSFER)], + ); set_state(&app, &id, ListingState::Aborted); run_abort_job(&app, &gone).await; assert_eq!(listing_state(&app, &id), ListingState::Aborted); @@ -1802,7 +1835,7 @@ async fn aborted_listing_relocks_when_the_lock_transfer_is_back() { for (what, rpc) in [ ( "a coin again", - facts(info_with(&other, 0, 0), &[(&lock_txid, 0)]), + facts(info_with(&other, 0, 0), &[(&lock_txid, 0, COV_TRANSFER)]), ), ("the owner again", facts(info_with(&lock_txid, 0, 0), &[])), ] { @@ -3067,3 +3100,697 @@ async fn listing_file_is_exported_only_once_the_finalize_is_mined() { .expect_err("not ours"); assert!(matches!(e, crate::error::AppError::NotFound(_)), "{e:?}"); } + +// --- 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() +} + +/// A coin as hsd's `GET /coin` sends it, with address, covenant type and +/// height (-1 in the mempool). +fn coin_at(txid: &str, vout: u32, address: &str, cov_type: u8, height: i64) -> NodeCoin { + serde_json::from_value(json!({ "hash": txid, "index": vout, "value": NAME_VALUE, + "address": address, "height": height, "covenant": { "type": cov_type, "items": [] } })) + .unwrap() +} + +/// A node answering `info` for NAME, `tip`, and `GET /coin` with `coins` +/// (hsd's empty 404 for any other outpoint). +fn chain_at(info: Value, tip: i64, coins: Vec) -> MockNodeRpc { + MockNodeRpc::new() + .with_name_info(info) + .with_blockchain_info(tip_info(tip)) + .with_get_coin(move |txid, vout| { + Ok(coins + .iter() + .find(|c| c.txid == txid && c.vout == vout) + .cloned()) + }) +} + +fn lock_address(net: Network) -> String { + derive_lock_key(&master(), net, 0, NAME).unwrap().address +} + +/// The lock TRANSFER mined at TRANSFER_HEIGHT (or in the mempool, -1). +fn lock_transfer_at(lock_txid: &str, height: i64) -> NodeCoin { + coin_at( + lock_txid, + 0, + &addr00(Network::Regtest).0, + COV_TRANSFER, + height, + ) +} + +fn set_lock_draft_status(app: &App, listing_id: &str, status: &str) { + with_db(app, |c| { + let d = queries::get_shakedex_listing(c, listing_id) + .unwrap() + .unwrap() + .lock_transfer_draft_id + .unwrap(); + c.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = ?2", + params![status, d], + ) + .unwrap(); + }); +} + +/// Finalize & sign on a ready fixture: the listing is Finalizing. Returns +/// the lock outpoint the FINALIZE creates. +async fn finalized(r: &Ready) -> (String, u32) { + answer(true); + let s = finalize_and_sign(r, &price("5")).await.unwrap(); + assert_eq!(s.state, ListingState::Finalizing); + ( + s.lock_txid.unwrap(), + u32::try_from(s.lock_vout.unwrap()).unwrap(), + ) +} + +/// R19: Locking becomes ReadyToFinalize at the first tip whose next block +/// may hold the FINALIZE (blocks_until_finalize == 0), read from the node's +/// own transfer height, and not one block earlier. +#[tokio::test] +async fn lockup_over_makes_the_listing_ready_to_finalize() { + 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; + let info = locked_name_info(RENEWAL, &lock_txid); + 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; + assert_eq!( + listing_state(&app, &id), + ListingState::Locking, + "one block early" + ); + run_abort_job(&app, &chain_at(info, tip, coin)).await; + assert_eq!(listing_state(&app, &id), ListingState::ReadyToFinalize); +} + +/// A reorg that moves the lock TRANSFER back (to a later block, or into the +/// mempool, where it is no longer the owner) makes a ReadyToFinalize listing +/// Locking again; while the lockup is still over it stays ReadyToFinalize. +#[tokio::test] +async fn ready_listing_goes_back_to_locking_on_a_reorg() { + let tip = ready_tip(Network::Regtest); + for (case, want) in [ + ("still mined at its height", ListingState::ReadyToFinalize), + ("mined in a later block", ListingState::Locking), + ("back in the mempool", ListingState::Locking), + ] { + 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, ListingState::ReadyToFinalize); + let chain = match case { + "still mined at its height" => chain_at( + locked_name_info(RENEWAL, &lock_txid), + tip, + vec![lock_transfer_at(&lock_txid, TRANSFER_HEIGHT)], + ), + "mined in a later block" => { + let mut info = locked_name_info(RENEWAL, &lock_txid); + info["info"]["transfer"] = (TRANSFER_HEIGHT + 5).into(); + chain_at( + info, + tip, + vec![lock_transfer_at(&lock_txid, TRANSFER_HEIGHT + 5)], + ) + } + _ => chain_at( + name_info(RENEWAL, 0, OWNER_TXID), + tip, + vec![lock_transfer_at(&lock_txid, -1)], + ), + }; + assert_eq!( + listing_state(&app, &id), + ListingState::ReadyToFinalize, + "{case}: before" + ); + run_abort_job(&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 +/// the before-lock job, which runs first in the same sync. +#[tokio::test] +async fn finalize_into_our_lock_is_never_an_abort() { + let r = ready_fixture("regtest").await; + let (fin, vout) = finalized(&r).await; + set_lock_draft_status(&r.app, &r.listing_id, "confirmed"); + let mut info = name_info(RENEWAL, 0, &fin); + info["info"]["owner"]["index"] = vout.into(); + let tip = ready_tip(Network::Regtest) + 1; + let chain = chain_at( + info, + tip, + vec![coin_at( + &fin, + vout, + &lock_address(Network::Regtest), + COV_FINALIZE, + tip, + )], + ); + run_abort_job(&r.app, &chain).await; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::Finalizing, + "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; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::Listed, + "a second sync" + ); +} + +/// Coordinator (b): a FINALIZE into our lock that this device did not build +/// (another device with the same seed) while this one still says +/// ReadyToFinalize is a Restored lock with that outpoint, never Aborted; a +/// coin at our lock that is not a FINALIZE leaves the listing as it is; the +/// owner coin elsewhere still aborts (T2). +#[tokio::test] +async fn finalize_into_our_lock_from_another_device_is_a_restored_lock() { + for (case, at_lock, cov, want) in [ + ( + "FINALIZE at our lock", + true, + COV_FINALIZE, + ListingState::Restored, + ), + ( + "TRANSFER at our lock", + true, + COV_TRANSFER, + ListingState::ReadyToFinalize, + ), + ( + "FINALIZE elsewhere", + false, + COV_FINALIZE, + ListingState::Aborted, + ), + ] { + let r = ready_fixture("regtest").await; + set_lock_draft_status(&r.app, &r.listing_id, "confirmed"); + let other = "0e".repeat(32); + let addr = if at_lock { + lock_address(Network::Regtest) + } else { + addr00(Network::Regtest).0 + }; + let tip = ready_tip(Network::Regtest) + 1; + let chain = chain_at( + name_info(RENEWAL, 0, &other), + tip, + vec![coin_at(&other, 0, &addr, cov, tip)], + ); + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::ReadyToFinalize, + "{case}: before" + ); + run_listing_jobs(&r.app, &chain).await; + let l = r.listing(); + assert_eq!(l.state, want, "{case}"); + if want == ListingState::Restored { + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(other.as_str()), Some(0)), + "{case}" + ); + } + } +} + +/// 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 +/// tracks, and the listing becomes a Restored lock with it, never Aborted. +#[tokio::test] +async fn dropped_finalize_mined_after_all_is_a_restored_lock() { + 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; + let dropped = chain_at( + r.info(), + tip, + vec![lock_transfer_at(&r.lock_txid, TRANSFER_HEIGHT)], + ); + run_listing_jobs(&r.app, &dropped).await; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::ReadyToFinalize + ); + + let mut info = name_info(RENEWAL, 0, &fin); + info["info"]["owner"]["index"] = vout.into(); + let mined = chain_at( + info, + tip + 1, + vec![coin_at( + &fin, + vout, + &lock_address(Network::Regtest), + COV_FINALIZE, + tip + 1, + )], + ); + run_listing_jobs(&r.app, &mined).await; + let l = r.listing(); + assert_eq!(l.state, ListingState::Restored); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(fin.as_str()), Some(i64::from(vout))) + ); +} + +/// R19: Finalizing → Listed once the lock coin is a FINALIZE at the lock +/// address mined in a block (one confirmation); a reorg that puts it back in +/// the mempool makes the listing Finalizing again; a coin at another +/// address or of another type changes nothing, in either direction. +#[tokio::test] +async fn mined_finalize_lists_the_listing_and_a_reorg_takes_it_back() { + let r = ready_fixture("regtest").await; + let (fin, vout) = finalized(&r).await; + let (lock, ours) = (lock_address(Network::Regtest), addr00(Network::Regtest).0); + let mined = ready_tip(Network::Regtest) + 1; + let steps: [(&str, &str, u8, i64, ListingState); 9] = [ + ( + "in the mempool", + &lock, + COV_FINALIZE, + -1, + ListingState::Finalizing, + ), + ("mined", &lock, COV_FINALIZE, mined, ListingState::Listed), + ( + "Listed, another address in the mempool", + &ours, + COV_FINALIZE, + -1, + ListingState::Listed, + ), + ( + "Listed, a TRANSFER in the mempool", + &lock, + COV_TRANSFER, + -1, + ListingState::Listed, + ), + ( + "reorg: back in the mempool", + &lock, + COV_FINALIZE, + -1, + ListingState::Finalizing, + ), + ( + "Finalizing, another address mined", + &ours, + COV_FINALIZE, + mined, + ListingState::Finalizing, + ), + ( + "Finalizing, a TRANSFER mined", + &lock, + COV_TRANSFER, + mined, + ListingState::Finalizing, + ), + ( + "mined again", + &lock, + COV_FINALIZE, + mined + 1, + ListingState::Listed, + ), + ( + "still mined", + &lock, + COV_FINALIZE, + mined + 1, + ListingState::Listed, + ), + ]; + for (case, addr, cov, height, want) in steps { + let chain = chain_at( + r.info(), + mined + 2, + vec![coin_at(&fin, vout, addr, cov, height)], + ); + run_listing_jobs(&r.app, &chain).await; + assert_eq!(listing_state(&r.app, &r.listing_id), want, "{case}"); + } +} + +/// Deviation 3: a FINALIZE refused by hsd (`failed`), `dropped`, or whose +/// draft is gone, with the lock TRANSFER a coin again, returns the listing +/// to ReadyToFinalize and drops its steps and file; a FINALIZE not sent yet, +/// still `broadcasted` or `broadcast_pending`, or a lock TRANSFER that is not +/// a coin, leaves it Finalizing. +#[tokio::test] +async fn refused_finalize_returns_the_listing_to_ready() { + for (case, status, transfer_coin, want) in [ + ("failed", "failed", true, ListingState::ReadyToFinalize), + ("dropped", "dropped", true, ListingState::ReadyToFinalize), + ("gone", "gone", true, ListingState::ReadyToFinalize), + ( + "failed, lock TRANSFER not a coin", + "failed", + false, + ListingState::Finalizing, + ), + ( + "dropped, lock TRANSFER not a coin", + "dropped", + false, + ListingState::Finalizing, + ), + ( + "signed, not sent yet", + "signed", + true, + ListingState::Finalizing, + ), + ("broadcasted", "broadcasted", true, ListingState::Finalizing), + ( + "broadcast_pending", + "broadcast_pending", + true, + ListingState::Finalizing, + ), + ] { + let r = ready_fixture("regtest").await; + let (fin, _) = finalized(&r).await; + let fin_draft = r.listing().lock_finalize_draft_id.unwrap(); + with_db(&r.app, |c| { + let s = if status == "gone" { "failed" } else { status }; + if s != "signed" { + queries::update_tx_draft_status(c, &fin_draft, s, None, Some(&fin)).unwrap(); + } + if status == "gone" { + queries::delete_tx_draft(c, &fin_draft).unwrap(); + } + }); + assert_eq!( + r.listing().state, + ListingState::Finalizing, + "{case}: before" + ); + let coins = if transfer_coin { + vec![lock_transfer_at(&r.lock_txid, TRANSFER_HEIGHT)] + } else { + vec![] + }; + run_finalize_job( + &r.app, + &chain_at(r.info(), ready_tip(Network::Regtest) + 1, coins), + ) + .await; + let l = r.listing(); + assert_eq!(l.state, want, "{case}"); + if want == ListingState::ReadyToFinalize { + assert_eq!( + ( + l.lock_txid, + l.lock_vout, + l.steps_json.as_str(), + l.listing_file_json + ), + (None, None, "[]", None), + "{case}" + ); + } else { + assert_eq!(l.lock_txid.as_deref(), Some(fin.as_str()), "{case}"); + assert_ne!(l.steps_json, "[]", "{case}"); + } + } +} + +/// Fail closed: a lock coin without address, covenant or height, a coin +/// read error, a name reply without owner or transfer height, a tip that +/// cannot be read, an owner coin at our lock without its address: nothing +/// changes, in either job. +#[tokio::test] +async fn finalize_facts_missing_change_nothing() { + use crate::error::AppError; + + // The finalize job, on a Finalizing listing. + let r = ready_fixture("regtest").await; + let (fin, vout) = finalized(&r).await; + let lock = lock_address(Network::Regtest); + let tip = ready_tip(Network::Regtest) + 2; + let strip = |field: &str| { + let mut v = serde_json::to_value(json!({ "hash": fin, "index": vout, "value": NAME_VALUE, + "address": lock, "height": tip, "covenant": { "type": COV_FINALIZE, "items": [] } })) + .unwrap(); + v.as_object_mut().unwrap().remove(field); + serde_json::from_value::(v).unwrap() + }; + for (case, coin) in [ + ("lock coin without address", strip("address")), + ("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; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::Finalizing, + "{case}" + ); + } + let coin_err = MockNodeRpc::new() + .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; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::Finalizing, + "coin read error" + ); + // A dead FINALIZE, but the lock TRANSFER cannot be read. + let fin_draft = r.listing().lock_finalize_draft_id.unwrap(); + with_db(&r.app, |c| { + queries::update_tx_draft_status(c, &fin_draft, "failed", None, Some(&fin)).unwrap(); + }); + let (f, v) = (fin.clone(), vout); + let transfer_err = MockNodeRpc::new().with_get_coin(move |txid, i| { + if txid == f && i == v { + Ok(None) + } else { + Err(AppError::Rpc("down".into())) + } + }); + run_finalize_job(&r.app, &transfer_err).await; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::Finalizing, + "transfer read error" + ); + + // The before-lock job, on a Locking listing at the ready tip. + 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; + let ready = ready_tip(Network::Regtest); + let coin = vec![lock_transfer_at(&lock_txid, TRANSFER_HEIGHT)]; + let mut no_transfer = locked_name_info(RENEWAL, &lock_txid); + no_transfer["info"] + .as_object_mut() + .unwrap() + .remove("transfer"); + let mut zero_transfer = locked_name_info(RENEWAL, &lock_txid); + zero_transfer["info"]["transfer"] = 0.into(); + let mut no_owner = locked_name_info(RENEWAL, &lock_txid); + no_owner["info"].as_object_mut().unwrap().remove("owner"); + for (case, chain) in [ + ( + "no transfer height", + chain_at(no_transfer, ready, coin.clone()), + ), + ( + "transfer height 0", + chain_at(zero_transfer, ready, coin.clone()), + ), + ("no owner", chain_at(no_owner, ready, coin.clone())), + ( + "tip unreadable", + MockNodeRpc::new() + .with_name_info(locked_name_info(RENEWAL, &lock_txid)) + .with_blockchain_info_err("down") + .with_get_coin({ + let c = coin.clone(); + move |txid, i| Ok(c.iter().find(|x| x.txid == txid && x.vout == i).cloned()) + }), + ), + ] { + run_abort_job(&app, &chain).await; + assert_eq!(listing_state(&app, &id), ListingState::Locking, "{case}"); + } + + // The before-lock job, on a ReadyToFinalize listing whose name left the + // lock TRANSFER: an owner coin at our lock that is not hsd's whole + // answer, or cannot be read, is no verdict (not Aborted). + let r = ready_fixture("regtest").await; + set_lock_draft_status(&r.app, &r.listing_id, "confirmed"); + let other = "0e".repeat(32); + let no_addr: NodeCoin = serde_json::from_value(json!({ "hash": other, "index": 0, + "value": NAME_VALUE, "height": tip, "covenant": { "type": COV_FINALIZE, "items": [] } })) + .unwrap(); + let no_cov: NodeCoin = serde_json::from_value(json!({ "hash": other, "index": 0, + "value": NAME_VALUE, "height": tip, "address": lock })) + .unwrap(); + for (case, chain) in [ + ( + "owner coin without address", + chain_at(name_info(RENEWAL, 0, &other), tip, vec![no_addr]), + ), + ( + "owner coin without covenant", + chain_at(name_info(RENEWAL, 0, &other), tip, vec![no_cov]), + ), + ( + "owner coin unreadable", + MockNodeRpc::new() + .with_name_info(name_info(RENEWAL, 0, &other)) + .with_blockchain_info(tip_info(tip)) + .with_get_coin({ + let o = other.clone(); + move |txid, _| { + if txid == o { + Err(AppError::Rpc("down".into())) + } else { + Ok(None) + } + } + }), + ), + ] { + run_listing_jobs(&r.app, &chain).await; + assert_eq!( + listing_state(&r.app, &r.listing_id), + ListingState::ReadyToFinalize, + "{case}" + ); + } +} + +/// The finalize job is a step of `run_sync_steps` in the app and the +/// daemon, and sends nothing: a node whose `sendrawtransaction` must never +/// be called, the FINALIZE mined at the lock address; the listing is Listed +/// in both. +#[tokio::test] +async fn sync_lists_the_listing_in_the_app_and_the_daemon() { + use crate::commands::sync::{run_sync_steps, SyncCaller, SyncStatus}; + + for caller in [SyncCaller::Daemon, SyncCaller::App] { + let r = ready_fixture("regtest").await; + let (fin, vout) = finalized(&r).await; + let tip = ready_tip(Network::Regtest) + 1; + + let mut node = mockito::Server::new_async().await; + let path = std::env::temp_dir().join(format!( + "namehold_sell_listed_{}_{caller:?}.db", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let db_path = path.to_str().unwrap().to_string(); + with_db(&r.app, |c| { + queries::set_setting(c, "node_rpc_url", &node.url()).unwrap(); + c.execute("VACUUM INTO ?1", params![db_path]).unwrap(); + }); + let send = node + .mock("POST", "/") + .match_body(mockito::Matcher::Regex("sendrawtransaction".into())) + .expect(0) + .create_async() + .await; + let _info = node + .mock("POST", "/") + .match_body(mockito::Matcher::PartialJson( + json!({ "method": "getblockchaininfo" }), + )) + .with_header("content-type", "application/json") + .with_body(rpc_ok(json!({ + "chain": "regtest", "blocks": tip, "headers": tip, + "verificationprogress": 1.0, "mediantime": SIGN_MTP + }))) + .create_async() + .await; + let _mined = node + .mock("GET", format!("/coin/{fin}/{vout}").as_str()) + .with_header("content-type", "application/json") + .with_body( + json!({ "hash": fin, "index": vout, "value": NAME_VALUE, "height": tip, + "address": lock_address(Network::Regtest), + "covenant": { "type": COV_FINALIZE, "action": "FINALIZE", "items": [] } }) + .to_string(), + ) + .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; + let conn = Connection::open(&path).unwrap(); + let got = queries::get_shakedex_listing(&conn, &r.listing_id) + .unwrap() + .unwrap(); + assert_eq!(got.state, ListingState::Listed, "{caller:?}"); + drop(conn); + let _ = std::fs::remove_file(&path); + } +} From 890f988ba861efb4337f16addf524daf85fe0c61 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 16:22:15 +0200 Subject: [PATCH 09/17] fix(shakedex): read an owner coin spent in the mempool and a mined lock TRANSFER that is not the owner as no verdict --- docs/specs/2026-10-05-shakedex-name-sales.md | 4 +- src-tauri/src/shakedex_jobs.rs | 42 ++++-- src-tauri/src/tests/shakedex_sell_tests.rs | 135 +++++++++++++++---- 3 files changed, 144 insertions(+), 37 deletions(-) diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index e78da6e..5456dd8 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 at signing minus 512 s, so it is valid at once by R3; it is taken when the steps are signed, 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`), `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 but no longer the owner (back in the mempool). Before aborting, the owner coin is looked up: 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. `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, while the lock TRANSFER is a coin again, returns it to ReadyToFinalize without its lock outpoint, steps and file. The two jobs never take the same listing (Locking, ReadyToFinalize and Aborted listings against Finalizing and Listed ones), 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 or lock coin without its address, covenant or 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, sync_lists_the_listing_in_the_app_and_the_daemon}`, `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`, `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`. +*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`), `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, while the lock TRANSFER is a coin again, returns it to ReadyToFinalize without its lock outpoint, steps and file. The two jobs never take the same listing (Locking, ReadyToFinalize and Aborted listings against Finalizing and Listed ones), 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}`, `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`, `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`. **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`); nothing is signed or written unless it is confirmed. diff --git a/src-tauri/src/shakedex_jobs.rs b/src-tauri/src/shakedex_jobs.rs index b743075..529e14a 100644 --- a/src-tauri/src/shakedex_jobs.rs +++ b/src-tauri/src/shakedex_jobs.rs @@ -853,8 +853,8 @@ enum LockOnChain { /// `None` when the reply leaves it out or says 0 (no TRANSFER, which /// cannot be while our TRANSFER owns the name: not hsd's whole answer). Owner { transfer: Option }, - /// The lock TRANSFER is a coin but not the owner yet: it is in the - /// mempool (hsd moves `owner` only when a block is connected). + /// The lock TRANSFER is a coin in the mempool (`height: -1`), not the + /// owner yet (hsd moves `owner` only when a block is connected). Pending, /// Neither: `owner` is another outpoint, or the name is revoked /// (a REVOKE leaves `owner` at the coin it spent and sets `revoked`, @@ -894,8 +894,15 @@ async fn lock_on_chain( .filter(|t| *t > 0); if revoked == 0 && index == 0 && hash.eq_ignore_ascii_case(lock_transfer_txid) { Ok(LockOnChain::Owner { transfer }) - } else if client.get_coin(lock_transfer_txid, 0).await?.is_some() { - Ok(LockOnChain::Pending) + } 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. + 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" + ))), + } } else { Ok(LockOnChain::Gone { owner: (hash.to_string(), index), @@ -912,15 +919,20 @@ enum InOurLock { /// At the listing's lock address, but another covenant (a purchase's /// TRANSFER or a cancel already): the name went through our lock. Other, - /// Elsewhere, or a coin hsd answers 404 for. + /// Elsewhere. No, + /// hsd's 404: `info.owner` is the owner at the tip, so its coin is + /// missing only while a mempool transaction spends it (`fullnode.js` + /// `getCoin`). Where the name goes is not known yet: no verdict until + /// that spend is mined and the owner moves to a coin hsd can show. + SpentInMempool, } /// Where the name's owner coin `owner` sits: at this listing's lock address -/// (as a FINALIZE, or another covenant) or elsewhere. A coin hsd answers 404 -/// for is [`InOurLock::No`] (T2's abort stands; the trace of a lock coin -/// spent since is T4's). A coin without address or covenant is not hsd's -/// whole answer: an error. +/// (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. async fn owner_in_our_lock( client: &dyn NodeRpc, network: Network, @@ -928,7 +940,7 @@ async fn owner_in_our_lock( owner: &(String, u32), ) -> Result { let Some(coin) = client.get_coin(&owner.0, owner.1).await? else { - return Ok(InOurLock::No); + return Ok(InOurLock::SpentInMempool); }; let (Some(address), Some(covenant)) = (coin.address.as_deref(), coin.covenant.as_ref()) else { return Err(AppError::Rpc(format!( @@ -996,10 +1008,13 @@ pub async fn refresh_listings_before_lock_step(db_path: &str, profile_id: &str) /// - 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 +/// spend is mined; /// - 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 but not the owner (back in the mempool) → -/// Locking. +/// 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. /// /// Locking again is refused while another listing of the name is open /// ([`queries::unabort_shakedex_listing`]). Anything the node does not @@ -1075,6 +1090,9 @@ async fn refresh_before_lock( // At our lock but not a FINALIZE: the name went through // our lock; T4 decides. InOurLock::Other => return Ok(()), + // The owner coin is spent in the mempool: no verdict + // until that spend is mined. + InOurLock::SpentInMempool => return Ok(()), InOurLock::No => {} } } diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index a46689e..389b32a 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -1833,9 +1833,16 @@ async fn aborted_listing_relocks_when_the_lock_transfer_is_back() { let id = open_listing(&app).unwrap().id; let other = "ee".repeat(32); for (what, rpc) in [ + // A reorg that undoes the abort puts the lock TRANSFER back in the + // mempool (hsd moves `owner` only when a block is connected), or + // makes it the owner again. ( - "a coin again", - facts(info_with(&other, 0, 0), &[(&lock_txid, 0, COV_TRANSFER)]), + "a coin again, in the mempool", + chain_at( + info_with(&other, 0, 0), + QUIET_TIP, + vec![lock_transfer_at(&lock_txid, -1)], + ), ), ("the owner again", facts(info_with(&lock_txid, 0, 0), &[])), ] { @@ -3132,12 +3139,43 @@ fn tip_info(tip: i64) -> crate::noncustodial::rpc::BlockchainInfo { .unwrap() } -/// A coin as hsd's `GET /coin` sends it, with address, covenant type and -/// height (-1 in the mempool). +/// hsd's name for a covenant type (`covenant.js`, `typesByVal`). +fn cov_action(cov_type: u8) -> &'static str { + match cov_type { + COV_REGISTER => "REGISTER", + COV_UPDATE => "UPDATE", + COV_TRANSFER => "TRANSFER", + COV_FINALIZE => "FINALIZE", + other => panic!("no action name for covenant type {other} in these tests"), + } +} + +/// 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). +fn coin_json(txid: &str, vout: u32, address: &str, cov_type: u8, height: i64) -> Value { + json!({ "version": 0, "height": height, "value": NAME_VALUE, "address": address, + "covenant": { "type": cov_type, "action": cov_action(cov_type), "items": [] }, + "coinbase": false, "hash": txid, "index": vout }) +} + +/// [`coin_json`] as the client reads it. fn coin_at(txid: &str, vout: u32, address: &str, cov_type: u8, height: i64) -> NodeCoin { - serde_json::from_value(json!({ "hash": txid, "index": vout, "value": NAME_VALUE, - "address": address, "height": height, "covenant": { "type": cov_type, "items": [] } })) - .unwrap() + serde_json::from_value(coin_json(txid, vout, address, cov_type, height)).unwrap() +} + +/// [`coin_json`] without `field`: not hsd's whole answer. +fn coin_without( + txid: &str, + vout: u32, + address: &str, + cov_type: u8, + height: i64, + field: &str, +) -> NodeCoin { + let mut v = coin_json(txid, vout, address, cov_type, height); + v.as_object_mut().unwrap().remove(field); + serde_json::from_value(v).unwrap() } /// A node answering `info` for NAME, `tip`, and `GET /coin` with `coins` @@ -3586,13 +3624,7 @@ async fn finalize_facts_missing_change_nothing() { let (fin, vout) = finalized(&r).await; let lock = lock_address(Network::Regtest); let tip = ready_tip(Network::Regtest) + 2; - let strip = |field: &str| { - let mut v = serde_json::to_value(json!({ "hash": fin, "index": vout, "value": NAME_VALUE, - "address": lock, "height": tip, "covenant": { "type": COV_FINALIZE, "items": [] } })) - .unwrap(); - v.as_object_mut().unwrap().remove(field); - serde_json::from_value::(v).unwrap() - }; + let strip = |field: &str| coin_without(&fin, vout, &lock, COV_FINALIZE, tip, field); for (case, coin) in [ ("lock coin without address", strip("address")), ("lock coin without covenant", strip("covenant")), @@ -3681,12 +3713,8 @@ async fn finalize_facts_missing_change_nothing() { let r = ready_fixture("regtest").await; set_lock_draft_status(&r.app, &r.listing_id, "confirmed"); let other = "0e".repeat(32); - let no_addr: NodeCoin = serde_json::from_value(json!({ "hash": other, "index": 0, - "value": NAME_VALUE, "height": tip, "covenant": { "type": COV_FINALIZE, "items": [] } })) - .unwrap(); - let no_cov: NodeCoin = serde_json::from_value(json!({ "hash": other, "index": 0, - "value": NAME_VALUE, "height": tip, "address": lock })) - .unwrap(); + let no_addr = coin_without(&other, 0, &lock, COV_FINALIZE, tip, "address"); + let no_cov = coin_without(&other, 0, &lock, COV_FINALIZE, tip, "covenant"); for (case, chain) in [ ( "owner coin without address", @@ -3768,9 +3796,13 @@ async fn sync_lists_the_listing_in_the_app_and_the_daemon() { .mock("GET", format!("/coin/{fin}/{vout}").as_str()) .with_header("content-type", "application/json") .with_body( - json!({ "hash": fin, "index": vout, "value": NAME_VALUE, "height": tip, - "address": lock_address(Network::Regtest), - "covenant": { "type": COV_FINALIZE, "action": "FINALIZE", "items": [] } }) + coin_json( + &fin, + vout, + &lock_address(Network::Regtest), + COV_FINALIZE, + tip, + ) .to_string(), ) .create_async() @@ -3794,3 +3826,60 @@ async fn sync_lists_the_listing_in_the_app_and_the_daemon() { let _ = std::fs::remove_file(&path); } } + +/// hsd's `info.owner` is the owner at the tip, and `GET /coin` answers 404 +/// for it only when a mempool transaction spends it (`fullnode.js` +/// `getCoin`): that says nothing about where the name is, so it is no +/// verdict — even with the lock TRANSFER gone and its draft confirmed. +#[tokio::test] +async fn owner_coin_spent_in_the_mempool_is_no_verdict() { + for state in [ListingState::Locking, ListingState::ReadyToFinalize] { + let (_node, _m, app) = lock_fixture("regtest", "mnemonic_hot", QUIET_TIP).await; + locked_on_chain(&app).await; + let id = open_listing(&app).unwrap().id; + set_state(&app, &id, state); + 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; + assert_eq!(listing_state(&app, &id), state, "{state:?}"); + } +} + +/// A lock TRANSFER that is a coin mined in a block but not the name's owner +/// is not a picture hsd draws (the owner moves when the block is +/// connected): no verdict, in either direction. +#[tokio::test] +async fn mined_lock_transfer_that_is_not_the_owner_changes_nothing() { + for state in [ + ListingState::Locking, + ListingState::ReadyToFinalize, + ListingState::Aborted, + ] { + 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 chain = chain_at( + name_info(RENEWAL, 0, OWNER_TXID), + ready_tip(Network::Regtest), + vec![lock_transfer_at(&lock_txid, TRANSFER_HEIGHT)], + ); + run_abort_job(&app, &chain).await; + assert_eq!(listing_state(&app, &id), state, "{state:?}: mined"); + let no_height = chain_at( + name_info(RENEWAL, 0, OWNER_TXID), + ready_tip(Network::Regtest), + vec![coin_without( + &lock_txid, + 0, + &addr00(Network::Regtest).0, + COV_TRANSFER, + -1, + "height", + )], + ); + run_abort_job(&app, &no_height).await; + assert_eq!(listing_state(&app, &id), state, "{state:?}: no height"); + } +} From 9ef133745196cc93317eae58a62909ffb2563fc2 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 16:30:01 +0200 Subject: [PATCH 10/17] feat(shakedex): remind daily that a locked name is ready to Finalize & sign --- src-tauri/src/commands/deadlines.rs | 115 ++++++++++++++++++++- src-tauri/src/tests/deadlines_cmd_tests.rs | 87 +++++++++++++++- 2 files changed, 197 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/deadlines.rs b/src-tauri/src/commands/deadlines.rs index 405915b..26afa9f 100644 --- a/src-tauri/src/commands/deadlines.rs +++ b/src-tauri/src/commands/deadlines.rs @@ -11,7 +11,10 @@ //! //! A third kind is not a deadline but a reminder: a name bought through //! Shakedex whose transfer lockup is over and that can be finalized now -//! ([`scan_purchase_deadlines`], R14). +//! ([`scan_purchase_deadlines`], R14). A fourth is the same kind of reminder +//! for a Shakedex listing whose transfer lockup is over and that waits for +//! Finalize & sign ([`scan_listing_ready_deadlines`], R19); unlike the +//! purchase one it repeats daily until Finalize & sign has run. //! //! The scanner core ([`scan_deadlines`]) is a PURE function: deadlines + //! config + previously-notified state → notifications to emit + new state. @@ -265,6 +268,45 @@ pub fn scan_purchase_deadlines( dedup_imminent(ready, config, previously_notified) } +/// A Shakedex listing whose transfer lockup is over: Finalize & sign can run +/// (R19). A reminder, repeated daily until it runs. +#[derive(Debug, Clone, PartialEq)] +pub struct ListingReadyDeadline { + pub wallet_profile_id: String, + pub name: String, + /// Part of the key, so locking the name again later is a new episode. + pub lock_transfer_txid: String, +} + +/// Day-scoped: a new key every unix day, so the dedup lets one notification +/// through per day while the listing waits. +fn listing_ready_key(d: &ListingReadyDeadline, day: i64) -> String { + format!( + "listing_ready:{}:{}:{}:{day}", + d.wallet_profile_id, d.name, d.lock_transfer_txid + ) +} + +/// Pure scanner for listings ready to finalize (R19): one notification per +/// listing per `day` (days since the Unix epoch, UTC), deduplicated and gated +/// like [`scan_deadlines`]. +pub fn scan_listing_ready_deadlines( + ready: &[ListingReadyDeadline], + config: &DeadlineNotifyConfig, + previously_notified: &BTreeSet, + day: i64, +) -> ScanResult { + let ready = ready.iter().map(|d| PendingNotification { + key: listing_ready_key(d, day), + title: "Ready to list".into(), + body: format!( + "{}: the transfer lockup is over — Finalize & sign its price to list it", + d.name + ), + }); + dedup_imminent(ready, config, previously_notified) +} + // --------------------------------------------------------------------------- // IO shell // --------------------------------------------------------------------------- @@ -412,6 +454,17 @@ pub struct ScanOutcome { pub async fn scan_deadline_notifications( app: tauri::AppHandle, state: State<'_, AppState>, +) -> Result { + let day = chrono::Utc::now().timestamp().div_euclid(86_400); + scan_deadline_notifications_on_day(app, state, day).await +} + +/// [`scan_deadline_notifications`] with the unix day given, so a test can +/// scan two days without a clock. +pub(crate) async fn scan_deadline_notifications_on_day( + app: tauri::AppHandle, + state: State<'_, AppState>, + day: i64, ) -> Result { // Finding 3: load config FIRST and bail out immediately when disabled — // BEFORE probing the node, collecting deadlines (a per-profile @@ -438,7 +491,7 @@ pub async fn scan_deadline_notifications( // same discipline as `read_renewals`). let live_height = crate::commands::node_readiness::node_tip_height_if_synced(&state).await; - let (previously_notified, reveal, renewal, ready) = { + let (previously_notified, reveal, renewal, ready, listings) = { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; let previously_notified = load_state(&queries::get_settings(&conn)?); let (reveal, renewal) = collect_deadlines(&conn, live_height)?; @@ -453,14 +506,27 @@ pub async fn scan_deadline_notifications( }, ) .collect(); - (previously_notified, reveal, renewal, ready) + let listings: Vec = queries::list_listings_ready_to_finalize(&conn)? + .into_iter() + .map( + |(wallet_profile_id, name, lock_transfer_txid)| ListingReadyDeadline { + wallet_profile_id, + name, + lock_transfer_txid, + }, + ) + .collect(); + (previously_notified, reveal, renewal, ready, listings) }; - // The two scans own disjoint key spaces, so their results merge by union. + // The scans own disjoint key spaces, so their results merge by union. let mut result = scan_deadlines(&reveal, &renewal, &config, &previously_notified); let purchases = scan_purchase_deadlines(&ready, &config, &previously_notified); result.notifications.extend(purchases.notifications); result.active_episodes.extend(purchases.active_episodes); + let listings = scan_listing_ready_deadlines(&listings, &config, &previously_notified, day); + result.notifications.extend(listings.notifications); + result.active_episodes.extend(listings.active_episodes); #[cfg_attr(test, allow(unused_mut))] let mut delivery_error = None; @@ -959,4 +1025,45 @@ mod tests { ); assert_eq!(result.notifications.len(), 2); } + + // --- listing ready to finalize (R19) ----------------------------------- + + fn listing(profile: &str, name: &str, txid: &str) -> ListingReadyDeadline { + ListingReadyDeadline { + wallet_profile_id: profile.into(), + name: name.into(), + lock_transfer_txid: txid.into(), + } + } + + /// R19: once a day while the listing waits for Finalize & sign; not twice + /// on one day; never while notifications are off. + #[test] + fn listing_ready_notifies_once_a_day() { + let ready = [listing("p1", "dexsale", "aa")]; + let d1 = scan_listing_ready_deadlines(&ready, &cfg(true), &Default::default(), 20_000); + assert_eq!(d1.notifications.len(), 1); + assert_eq!(d1.notifications[0].key, "listing_ready:p1:dexsale:aa:20000"); + assert_eq!(d1.notifications[0].title, "Ready to list"); + assert_eq!( + d1.notifications[0].body, + "dexsale: the transfer lockup is over \u{2014} Finalize & sign its price to list it" + ); + let same_day = + scan_listing_ready_deadlines(&ready, &cfg(true), &d1.active_episodes, 20_000); + assert!(same_day.notifications.is_empty()); + let next_day = + scan_listing_ready_deadlines(&ready, &cfg(true), &d1.active_episodes, 20_001); + assert_eq!(next_day.notifications.len(), 1); + assert!( + !next_day + .active_episodes + .contains("listing_ready:p1:dexsale:aa:20000"), + "yesterday's key is dropped" + ); + let off = scan_listing_ready_deadlines(&ready, &cfg(false), &d1.active_episodes, 20_001); + assert!(off.notifications.is_empty()); + let done = scan_listing_ready_deadlines(&[], &cfg(true), &next_day.active_episodes, 20_001); + assert!(done.active_episodes.is_empty()); + } } diff --git a/src-tauri/src/tests/deadlines_cmd_tests.rs b/src-tauri/src/tests/deadlines_cmd_tests.rs index 3febd52..a771cff 100644 --- a/src-tauri/src/tests/deadlines_cmd_tests.rs +++ b/src-tauri/src/tests/deadlines_cmd_tests.rs @@ -9,7 +9,7 @@ //! (which deadlines were newly notified) and the persisted state, not on any //! real system notification. -use crate::commands::deadlines::scan_deadline_notifications; +use crate::commands::deadlines::{scan_deadline_notifications, scan_deadline_notifications_on_day}; use crate::db; use crate::tests::names_cmd_tests::{create_full_test_state, insert_valid_profile, mock_app_with}; use tauri::Manager; @@ -524,3 +524,88 @@ async fn purchase_ready_to_finalize_respects_disabled_setting() { assert!(!outcome.enabled); assert!(outcome.notified.is_empty()); } + +// --- Shakedex listing ready to finalize (R19) --- + +fn seed_listing(conn: &rusqlite::Connection, profile_id: &str, id: &str, name: &str, state: &str) { + db::queries::insert_shakedex_listing( + conn, + &db::queries::ShakedexListing { + id: id.into(), + wallet_profile_id: profile_id.into(), + name: name.into(), + mode: db::queries::ListingMode::BuyNow, + state: state.parse().unwrap(), + lock_pubkey_hex: "02".repeat(33), + lock_transfer_draft_id: None, + lock_finalize_draft_id: None, + lock_transfer_txid: Some(format!("{id}-tx")), + 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: the reminder appears once the lockup is over (ReadyToFinalize, not +/// Locking), comes once a day until Finalize & sign runs, and is gone from +/// then on (Finalizing). +#[tokio::test] +async fn listing_ready_repeats() { + let state = create_full_test_state(); + let profile_id = { + let conn = state.db.lock().unwrap(); + let id = insert_valid_profile(&conn, "mainnet"); + enable_notifications(&conn, "144", "30"); + seed_listing(&conn, &id, "l1", "forsale", "ready_to_finalize"); + seed_listing(&conn, &id, "l2", "stilllocking", "locking"); + id + }; + let app = mock_app_with(state); + let scan = |day| scan_deadline_notifications_on_day(app.handle().clone(), app.state(), day); + let d1 = scan(20_000).await.unwrap(); + assert_eq!(d1.notified.len(), 1, "{:?}", d1.notified); + assert_eq!( + d1.notified[0].key, + format!("listing_ready:{profile_id}:forsale:l1-tx:20000") + ); + assert!( + scan(20_000).await.unwrap().notified.is_empty(), + "once a day" + ); + let d2 = scan(20_001).await.unwrap(); + assert_eq!(d2.notified.len(), 1, "again the next day"); + + { + let s: tauri::State = app.state(); + let conn = s.db.lock().unwrap(); + conn.execute( + "UPDATE shakedex_listings SET state = 'finalizing' WHERE id = 'l1'", + [], + ) + .unwrap(); + } + assert!( + scan(20_002).await.unwrap().notified.is_empty(), + "gone once Finalize & sign ran" + ); + let s: tauri::State = app.state(); + let conn = s.db.lock().unwrap(); + let raw = db::queries::get_settings(&conn).unwrap()["deadline_notify_state"].clone(); + assert!(!raw.contains("listing_ready:"), "{raw}"); +} From 3ed33c33999b3d47befacc166e633fec6502cfcd Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 16:42:05 +0200 Subject: [PATCH 11/17] test(shakedex): the shakedex CLI buys a Namehold Buy Now on a live node --- docs/REGTEST_TESTING.md | 5 +- src-tauri/src/tests/live_node_it.rs | 257 ++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) diff --git a/docs/REGTEST_TESTING.md b/docs/REGTEST_TESTING.md index 7f20f97..806b59a 100644 --- a/docs/REGTEST_TESTING.md +++ b/docs/REGTEST_TESTING.md @@ -380,7 +380,7 @@ HNS_IT_NODE_URL=http://127.0.0.1:14037 HNS_IT_NODE_API_KEY=test HNS_IT_SHAKEDEX= cargo test --manifest-path src-tauri/Cargo.toml --lib live_node_it::shakedex_ -- --test-threads=1 --nocapture ``` -The tests drive the CLI through `scripts/shakedex-cli-sell.sh`, with the node's hsd wallet as the seller and as the other buyer. Each run registers fresh names (open, two bids, reveal, register, mining between the phases), so a used chain works, up to a point: regtest halves the block subsidy every 2500 blocks (`halvingInterval`), and the tests fund their wallets by mining, so a chain some tens of thousands of blocks tall leaves them too little to spend. A full run of the live suite mines about 10 000 blocks (the Shakedex tests about 3000 of them, registering names and funding buyers), so start over after two runs; the Shakedex buyer refuses to start on a chain past 30 000 blocks and says why; `scripts/regtest.sh reset` starts over. Each test funds an account of its own (`seeded_conn_regtest` hands out a fresh one): the tests once shared account 0, which gathered thousands of coinbases a run, until a sweep of it outgrew a standard transaction and stalled every later test behind an unmined transaction. The R9 test below mines about 5000 blocks and takes them back out with `invalidateblock` before it asserts anything, so it leaves the chain as tall as it found it. The R31 test (`shakedex_lock_refused_near_expiry`) does the same toward its own name's expiry, mining to an address no profile derives, and a guard takes the blocks back out even if the test panics first. The T2 lock tests (`shakedex_lock_*`, `shakedex_cancel_transfer_aborts_the_listing_on_chain`) are gated by `HNS_IT_SHAKEDEX` like the rest, but use no CLI: they register a name with the wallet itself, so they also run against a node at another port. The script clones `shadstoneofficial/shakedex` at the pinned commit `2c4fa04eab68a528e758598d11b5da5666113b11` into `SHAKEDEX_WORK` once and keeps the CLI's database there (the tests default it to `$TMPDIR/namehold-shakedex-cli`; run by hand, the script uses a fresh temp dir unless it is set); nothing is installed globally. `HNS_IT_SHAKEDEX` set to anything but `1` or empty, or set to `1` without `HNS_IT_NODE_URL`, or with a node URL other than the regtest RPC port (14037), fails the tests instead of skipping them. It can be run by hand too: +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, and `shakedex_cancel_transfer_aborts_the_listing_on_chain`) are gated by `HNS_IT_SHAKEDEX` like the rest, but use no CLI: they register a name with the wallet itself, so they also run against a node at another port. The script clones `shadstoneofficial/shakedex` at the pinned commit `2c4fa04eab68a528e758598d11b5da5666113b11` into `SHAKEDEX_WORK` once and keeps the CLI's database there (the tests default it to `$TMPDIR/namehold-shakedex-cli`; run by hand, the script uses a fresh temp dir unless it is set); nothing is installed globally. `HNS_IT_SHAKEDEX` set to anything but `1` or empty, or set to `1` without `HNS_IT_NODE_URL`, or with a node URL other than the regtest RPC port (14037), fails the tests instead of skipping them. It can be run by hand too: | Command | What the CLI does | |---|---| @@ -394,6 +394,7 @@ The tests drive the CLI through `scripts/shakedex-cli-sell.sh`, with the node's 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_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. @@ -411,7 +412,7 @@ Some listings the CLI cannot write: it publishes a market fee only through Learn - `shakedex_purchase_at_a_same_price_step_not_valid_is_not_sent` — two steps at one price, the purchase signed at the later; the blocks that moved the median time past it are taken back, so only the earlier step is valid, and the purchase is refused before it is sent. Checking the price alone sent it, and hsd answered with its txid although it could only hold it as non-final. - `shakedex_purchase_the_node_lost_is_given_up_after_mempool_expiry` — the purchase's block is invalidated (the node then has nothing of it); with the daemon's no-resend rule it waits, still unconfirmed one block before hsd's mempool expiry and lost with nothing paid at it, and nothing is sent again. -`live_send_pays_its_fee_rate_on_vsize` (no CLI needed) checks a send against the rate hsd itself reports for it, worked out on the virtual size. `shakedex_purchase_and_finalize_pay_their_fee_rate_on_vsize` does the same for a purchase and its FINALIZE built at 20 doos/byte, above the 5 doos/byte floor every other test's rate falls under. +`live_send_pays_its_fee_rate_on_vsize` (no CLI needed) checks a send against the rate hsd itself reports for it, worked out on the virtual size. `shakedex_purchase_and_finalize_pay_their_fee_rate_on_vsize` does the same for a purchase and its FINALIZE built at 20 doos/byte, above the 5 doos/byte floor every other test's rate falls under. `shakedex_lock_finalize_pays_its_fee_rate_on_vsize` (no CLI) does it for the seller's FINALIZE into the lock built at 20 doos/byte. The tests move the node's clock forward with `setmocktime` to make price steps valid. hsd keeps that as an offset that goes on ticking, and the median time never goes back, so the clock is only ever moved forward; never run `setmocktime 0` against this node, which sets its clock to 0 and stalls mining (`scripts/regtest.sh reset` starts over). diff --git a/src-tauri/src/tests/live_node_it.rs b/src-tauri/src/tests/live_node_it.rs index a3db07d..1bea8f5 100644 --- a/src-tauri/src/tests/live_node_it.rs +++ b/src-tauri/src/tests/live_node_it.rs @@ -5938,6 +5938,263 @@ async fn shakedex_lock_refused_near_expiry() { ); } +/// Run `hsw-rpc` against the regtest wallet (the CLI's buyer); its stdout. +fn hsw_rpc(api_key: &str, args: &[&str]) -> String { + let out = std::process::Command::new("hsw-rpc") + .arg("--network=regtest") + .arg(format!("--api-key={api_key}")) + .args(args) + .output() + .expect("run hsw-rpc (scripts/regtest.sh --with-wallet)"); + assert!( + out.status.success(), + "hsw-rpc {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout) + .expect("utf-8") + .trim() + .trim_matches('"') + .to_string() +} + +/// Run 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; + *app.state::().db.lock().unwrap() = conn; + res.expect("finalize job runs"); +} + +/// A fresh name locked on chain and its lockup mined out; the listing +/// ReadyToFinalize by the job, not by hand. `(app, client, our address, +/// name, listing id)`. +async fn ready_to_finalize_on_chain( + url: &str, + key: &str, + prefix: &str, +) -> ( + tauri::App, + NodeRpcClient, + String, + String, + String, +) { + let (app, cl, addr, name) = own_a_name(url, key, prefix).await; + lock_on_chain(&app, &cl, &addr, &name).await; + let id = open_listing(&app, &name).expect("listing").id; + // The lock TRANSFER is mined at the tip; the FINALIZE is valid once + // tip + 1 >= its height + lockup, i.e. after lockup - 1 more blocks. + cl.generate_to_address(NET.name_params().transfer_lockup - 2, &addr) + .await + .expect("mine"); + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + assert_eq!( + listing_state(&app, &id), + ListingState::Locking, + "one block before the lockup ends" + ); + cl.generate_to_address(1, &addr).await.expect("mine"); + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::ReadyToFinalize); + (app, cl, addr, name, id) +} + +/// Finalize & sign `price` HNS at `per_byte`, the R20 prompt confirmed +/// through the test queue; send the FINALIZE through the one broadcast path +/// and mine it. The summary. +async fn finalize_and_sign_on_chain( + app: &tauri::App, + cl: &NodeRpcClient, + addr: &str, + id: &str, + price: &str, + per_byte: u64, +) -> crate::commands::shakedex::ListingSummary { + unlock(app); + crate::commands::secure_prompt::push_test_answer( + crate::commands::secure_prompt::SecurePromptResult { + value: None, + confirmed: true, + }, + ); + let s = crate::commands::shakedex::finalize_and_sign_confirmed( + &app.state(), + app.handle(), + id, + &[crate::commands::shakedex::StepInput { + price: price.into(), + }], + Some(per_byte), + ) + .await + .expect("finalize & sign"); + assert_eq!(s.state, ListingState::Finalizing); + let draft = s.finalize_draft_id.clone().expect("the FINALIZE draft"); + let bc = broadcast_tx_draft(app.state(), draft.clone()) + .await + .expect("broadcast"); + assert_eq!(bc.status, "broadcasted"); + assert_eq!( + Some(bc.txid.clone()), + s.lock_txid, + "the lock coin is an output of the FINALIZE" + ); + settle(app, cl, addr, &draft).await; + s +} + +/// R30, selling half: Namehold locks a name, waits out the lockup, Finalize +/// & signs a Buy Now; once its FINALIZE is mined the listing is Listed, and +/// the shakedex CLI buys the exported listing file. On chain: the name's +/// owner is the fill's TRANSFER out of our lock, committing to an address of +/// the CLI's wallet, and the fill pays our payment address exactly the price. +#[tokio::test] +async fn shakedex_namehold_buy_now_is_bought_by_the_cli() { + let Some((url, key, cli)) = shakedex_env("shakedex_namehold_buy_now_is_bought_by_the_cli") + else { + return; + }; + let (app, cl, addr, name, id) = ready_to_finalize_on_chain(&url, &key, "nhsell").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(); + + // The FINALIZE on hsd: the name's owner is the lock coin, a FINALIZE at + // the lock address of the key derived here. + let lock = crate::noncustodial::shakedex::lock_key::derive_lock_key( + &master(), + NET, + test_acct(), + &name, + ) + .unwrap(); + assert_eq!(name_owner(&cl, &name).await, (lock_txid.clone(), lock_vout)); + let coin = cl + .get_coin(&lock_txid, lock_vout) + .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 + ); + assert!(coin.mined_height().unwrap().is_some(), "mined: {coin:?}"); + // Listed by the listing jobs on the live node (the same functions + // run_sync_steps runs; that wiring is pinned by + // shakedex_sell_tests::sync_lists_the_listing_in_the_app_and_the_daemon). + assert_eq!( + listing_state(&app, &id), + ListingState::Finalizing, + "sent and mined, not yet looked at" + ); + 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 CLI's hsd wallet pays: give it coins first (a fresh chain has none + // in it), then let them mature. + 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.clone())); + let bought = + std::fs::read_to_string(cli.work.join("fill.json")).expect("the file the CLI read"); + assert_eq!( + bought, file, + "the CLI bought the exported file, byte for byte" + ); + + let (fill_txid, fill_vout) = name_owner(&cl, &name).await; + assert_ne!(fill_txid, lock_txid, "the name moved out of the lock"); + let fill = cl.get_tx_by_hash(&fill_txid).await.expect("fill tx"); + assert!( + fill["inputs"].as_array().expect("inputs").iter().any(|i| { + i["prevout"]["hash"].as_str() == Some(lock_txid.as_str()) + && i["prevout"]["index"].as_u64() == Some(u64::from(lock_vout)) + }), + "the fill spends our lock coin: {fill}" + ); + let out = cl + .get_coin(&fill_txid, fill_vout) + .await + .expect("coin") + .expect("the fill's TRANSFER"); + assert!(out.mined_height().unwrap().is_some(), "mined: {out:?}"); + let cov = out.covenant.as_ref().expect("covenant"); + assert_eq!(cov.kind, crate::noncustodial::sync::COV_TRANSFER); + assert_eq!( + out.address.as_deref(), + Some(lock.address.as_str()), + "the TRANSFER out of our lock stays at it until finalized" + ); + assert_eq!(cov.items[2], "00", "witness version 0"); + let hash: [u8; 20] = hex::decode(&cov.items[3]) + .unwrap() + .try_into() + .expect("a 20-byte P2WPKH program"); + let buyer = crate::noncustodial::address::encode_p2wpkh(NET, &hash).unwrap(); + let info: serde_json::Value = + serde_json::from_str(&hsw_rpc(&key, &["getaddressinfo", &buyer])).expect("json"); + assert_eq!( + info["ismine"], true, + "the TRANSFER commits to the CLI wallet: {buyer} {info}" + ); + let pay = s.payment_address.clone().expect("payment address"); + assert_eq!( + paid_to(&cl, &fill_txid, &pay).await, + 3_000_000, + "our payment address got exactly the price" + ); +} + +/// 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. +#[tokio::test] +async fn shakedex_lock_finalize_pays_its_fee_rate_on_vsize() { + let Some((url, key)) = shakedex_node_env("shakedex_lock_finalize_pays_its_fee_rate_on_vsize") + else { + return; + }; + let per_byte = 20; + let (app, cl, addr, _name, id) = ready_to_finalize_on_chain(&url, &key, "nhrate").await; + let s = finalize_and_sign_on_chain(&app, &cl, &addr, &id, "3", per_byte).await; + let draft = draft_status(&app, s.finalize_draft_id.as_deref().unwrap()); + let fee = + serde_json::from_str::(&draft.summary_json).unwrap()["feeDoos"].clone(); + let tx = cl + .get_tx_by_hash(draft.txid.as_deref().expect("sent")) + .await + .expect("tx"); + assert!(tx["height"].as_i64().unwrap_or(-1) > 0, "mined: {tx}"); + assert_eq!(tx["fee"], fee, "{tx}"); + let rate = tx["rate"].as_u64().expect("rate"); + let asked = per_byte * 1000; + assert!( + (asked..=asked * 102 / 100).contains(&rate), + "asked {asked} doos/kvB, hsd reports {rate}: {tx}" + ); +} + /// #65 on a live node: a transaction pays the fee rate asked for on hsd's /// virtual size, not on its raw size. hsd's `GET /tx/:hash` reports the fee /// and the rate it works out on `getVirtualSize()`; the raw-size bug paid From 57536a99b37155ca77ce902f36ea879b358783ca Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 16:59:08 +0200 Subject: [PATCH 12/17] fix(shakedex): one rule for the lock TRANSFER's ownership and lockup, and a reason for each ended listing Finalize & sign and the sync job now ask whether the lock TRANSFER owns the name through one helper (`sell::lock_transfer_owns_name`) and take the lockup's start from the same fact, hsd's `info.transfer` (`sell::transfer_height`), the field hsd's FINALIZE rule reads; Finalize & sign no longer reads the TRANSFER coin's height. Finalize & sign and the listing file export refuse an Aborted, Expired, Cancelled, Sold or Restored listing with that state's own reason, and the export is allowed only in the named `ListingState::EXPORT_ALLOWED` states. Shared pieces: the profile network lives in `db::queries::profile_network` (re-exported as `active_profile::profile_network_from_conn`), the draft-status predicates are two named rules (`draft_alive`, `draft_may_still_land`), the selling commands take the signer through one helper, and the jobs' listing sets are named consts with a test that they are disjoint and hold no Restored listing. --- docs/specs/2026-10-05-shakedex-name-sales.md | 2 +- src-tauri/src/commands/active_profile.rs | 19 +-- src-tauri/src/commands/shakedex.rs | 93 ++++++++----- src-tauri/src/db/queries.rs | 93 +++++++++---- src-tauri/src/noncustodial/shakedex/sell.rs | 31 +++++ src-tauri/src/shakedex_jobs.rs | 37 ++--- .../src/tests/shakedex_layering_tests.rs | 8 +- src-tauri/src/tests/shakedex_sell_tests.rs | 130 +++++++++++++++--- 8 files changed, 294 insertions(+), 119 deletions(-) diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index 5456dd8..51d9411 100644 --- a/docs/specs/2026-10-05-shakedex-name-sales.md +++ b/docs/specs/2026-10-05-shakedex-name-sales.md @@ -130,7 +130,7 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI *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`). **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` (refused while the listing is Locking, ReadyToFinalize or Finalizing: the file leaves the wallet only once its FINALIZE into the lock is mined), `noncustodial/shakedex/listing_file.rs::write_listing_file` (read back by the strict parser `ListingFile::parse` before it is returned). +*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). *Pinned:* `learnhns_tests::{no_http_off_mainnet, upload_shape, pending_listing_shape, refresh_status_shape}`, `shakedex_jobs_tests::{publishes_pending_on_day_zero, publishes_active_after_one_confirmation, nothing_published_when_the_box_is_off, expires_at_refreshed_before_it_lapses}`, `shakedex_listing_file_tests::written_listing_file_round_trips_through_the_strict_parser`, `shakedex_sell_tests::listing_file_is_exported_only_once_the_finalize_is_mined`. **R24 — Reverse auctions on a market that sells one price.** All steps (2 to 1000, at least 15 minutes apart) are signed at once and the listing file carries them all. LearnHNS gets the current step (R3) as a single-step upload, replaced as the schedule moves. A future step leaking early is harmless because of R3's lock time and sequence. Out-of-range schedules are refused in the backend, with the reason: fewer than 2 or more than 1000 steps, steps closer than 15 minutes, prices not strictly falling, a floor of 0 or above the start. diff --git a/src-tauri/src/commands/active_profile.rs b/src-tauri/src/commands/active_profile.rs index 01d31f6..08ba490 100644 --- a/src-tauri/src/commands/active_profile.rs +++ b/src-tauri/src/commands/active_profile.rs @@ -4,7 +4,6 @@ //! `commands/namebase.rs`. use crate::db; -use crate::error::AppError; use crate::noncustodial::network::Network; use crate::AppState; @@ -50,20 +49,10 @@ pub(crate) fn active_profile_network_opt(state: &AppState) -> Option { active_profile_network_opt_from_conn(&conn) } -/// The `Network` of one *named* profile, or an error when the profile is -/// missing or its stored string does not parse. For user-triggered commands -/// and read models, where guessing mainnet would compute the wrong answer -/// (a balance with the wrong coinbase maturity, a renewal window aged by a -/// wall clock regtest does not keep) and CODING_STANDARDS says a DB failure -/// is returned, not swallowed. -pub(crate) fn profile_network_from_conn( - conn: &rusqlite::Connection, - profile_id: &str, -) -> Result { - let profile = db::queries::get_wallet_profile(conn, profile_id)? - .ok_or_else(|| AppError::NotFound(format!("wallet profile {profile_id}")))?; - crate::noncustodial::derivation::network_from_profile(&profile.network) -} +/// The `Network` of one *named* profile, or an error (missing profile, +/// unparseable network): [`db::queries::profile_network`], re-exported under +/// the name the commands use. +pub(crate) use db::queries::profile_network as profile_network_from_conn; /// The `Network` of one *named* profile, or `None` when the profile is /// missing, the DB errors, or the stored string does not parse. diff --git a/src-tauri/src/commands/shakedex.rs b/src-tauri/src/commands/shakedex.rs index 8049b4a..a57cd2a 100644 --- a/src-tauri/src/commands/shakedex.rs +++ b/src-tauri/src/commands/shakedex.rs @@ -24,7 +24,7 @@ use crate::noncustodial::derivation; use crate::noncustodial::network::Network; use crate::noncustodial::rpc::{self, ChainSource, NodeRpcClient}; use crate::noncustodial::send::{self, SpendableCoin, DUST_THRESHOLD}; -use crate::noncustodial::session::session_ttl_ms; +use crate::noncustodial::session::{session_ttl_ms, SignerSession}; use crate::noncustodial::shakedex::listing_file::{ write_listing_file, ListingFile, NewListingFile, PriceStep, MAX_LISTING_FILE_BYTES, }; @@ -641,15 +641,27 @@ pub(crate) fn build_lock_draft_inner( draft_ctx::draft_summary(conn, &draft_id) } -/// The unlocked signer session of the active profile, or `WalletLocked`. -/// Checked before a selling command reads anything else. -fn authorize_signer(state: &State<'_, AppState>, ctx: &Ctx) -> Result<(), AppError> { +/// Run `f` on the unlocked signer session of the active profile, authorized +/// again for it: `WalletLocked` when no session is open. The one place the +/// selling commands take the signer, so every use checks it the same way. +fn with_signer( + state: &State<'_, AppState>, + ctx: &Ctx, + f: impl FnOnce(&mut SignerSession) -> Result, +) -> Result { let mut slot = state .signer .lock() .map_err(|e| AppError::Lock(e.to_string()))?; let session = slot.as_mut().ok_or(AppError::WalletLocked)?; - session.authorize(&ctx.profile_id, session_ttl_ms(&ctx.settings)) + session.authorize(&ctx.profile_id, session_ttl_ms(&ctx.settings))?; + f(session) +} + +/// The unlocked signer session of the active profile, or `WalletLocked`. +/// Checked before a selling command reads anything else. +fn authorize_signer(state: &State<'_, AppState>, ctx: &Ctx) -> Result<(), AppError> { + with_signer(state, ctx, |_| Ok(())) } /// The lock key of `name` (ADR 0004), derived from the seed of the unlocked @@ -660,13 +672,9 @@ fn derive_listing_key( ctx: &Ctx, name: &str, ) -> Result { - let mut slot = state - .signer - .lock() - .map_err(|e| AppError::Lock(e.to_string()))?; - let session = slot.as_mut().ok_or(AppError::WalletLocked)?; - session.authorize(&ctx.profile_id, session_ttl_ms(&ctx.settings))?; - derive_lock_key(session.master()?, ctx.network, ctx.account, name) + with_signer(state, ctx, |session| { + derive_lock_key(session.master()?, ctx.network, ctx.account, name) + }) } /// One price step the seller typed (R19): HNS as text, so the backend can @@ -725,9 +733,11 @@ pub(crate) async fn prepare_lock_finalize( "Finalize & sign opens after the transfer lockup".into(), )) } - _ => { + other => { return Err(AppError::InvalidInput( - "this listing is already finalized into its lock".into(), + listing_over(other) + .unwrap_or("this listing is already finalized into its lock") + .into(), )) } } @@ -817,7 +827,7 @@ pub(crate) async fn prepare_lock_finalize( let Some(revoked) = info.get("revoked").and_then(|r| r.as_u64()) else { return Err(could_not_check("whether the name was revoked", &name)); }; - if revoked != 0 || owner_index != 0 || !owner_hash.eq_ignore_ascii_case(&lock_transfer_txid) { + if !sell::lock_transfer_owns_name(owner_hash, owner_index, revoked, &lock_transfer_txid) { return Err(AppError::InvalidInput(format!( "'{name}' is no longer held by its lock transfer: there is nothing to finalize" ))); @@ -831,10 +841,10 @@ pub(crate) async fn prepare_lock_finalize( "the lock transfer is no longer unspent: the name may already be finalized".into(), ) })?; - let transfer_height = coin - .mined_height() - .map_err(|_| could_not_check("the lock transfer's height", &name))? - .ok_or_else(|| AppError::InvalidInput("the lock transfer is not mined yet".into()))?; + // The lockup counts from hsd's `info.transfer`, the fact hsd's FINALIZE + // rule reads, as the sync job does (`sell::transfer_height`). + let transfer_height = sell::transfer_height(info) + .ok_or_else(|| could_not_check("the TRANSFER's block", &name))?; let params = ctx.network.name_params(); let remaining = params.blocks_until_finalize(transfer_height, tip); if remaining > 0 { @@ -999,13 +1009,7 @@ pub(crate) async fn finalize_and_sign_confirmed( let payment = output_address_from_string(net, &p.payment_address)?; // One unlock (R19): the session is authorized once for the FINALIZE and // every step; the confirmation may have outlasted the earlier check. - let (signed_hex, signed) = { - let mut slot = state - .signer - .lock() - .map_err(|e| AppError::Lock(e.to_string()))?; - let session = slot.as_mut().ok_or(AppError::WalletLocked)?; - session.authorize(&p.ctx.profile_id, session_ttl_ms(&p.ctx.settings))?; + let (signed_hex, signed) = with_signer(state, &p.ctx, |session| { let (hex, txid) = actions::sign_plan(session, &p.plan.plan)?; if txid != p.plan.txid { return Err(AppError::Other( @@ -1034,8 +1038,8 @@ pub(crate) async fn finalize_and_sign_confirmed( }) }) .collect::, _>>()?; - (hex, signed) - }; + Ok((hex, signed)) + })?; let expires_at = p.mtp + sell::LISTING_LIFETIME_SECS; let file = write_listing_file( &NewListingFile { @@ -1102,6 +1106,28 @@ pub(crate) async fn finalize_and_sign_confirmed( ListingSummary::of(&l) } +/// Why Finalize & sign and the export refuse a listing that is over or was +/// finalized elsewhere, by its state; `None` for every other state. +fn listing_over(state: ListingState) -> Option<&'static str> { + match state { + ListingState::Aborted => Some( + "this listing was aborted: the name left its lock transfer before the finalize \ + into the lock, so there is nothing to finalize, sign or export; lock the name \ + again to sell it", + ), + ListingState::Expired => Some("the name expired while it was locked: this listing is over"), + ListingState::Cancelled => { + Some("this listing is cancelled: its signed prices can no longer be used") + } + 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", + ), + _ => None, + } +} + /// R23: the saved listing file of one of the profile's listings, once its /// FINALIZE into the lock is mined (Listed or later): before that its steps /// are over a coin that may never exist. @@ -1113,12 +1139,13 @@ pub(crate) fn export_listing_file_from_conn( let l = queries::get_shakedex_listing(conn, listing_id)? .filter(|l| l.wallet_profile_id == profile_id) .ok_or_else(|| AppError::NotFound(format!("listing {listing_id}")))?; - if matches!( - l.state, - ListingState::Locking | ListingState::ReadyToFinalize | ListingState::Finalizing - ) { + if !ListingState::EXPORT_ALLOWED.contains(&l.state) { return Err(AppError::InvalidInput( - "the listing file can be exported once the finalize into the lock is mined".into(), + listing_over(l.state) + .unwrap_or( + "the listing file can be exported once the finalize into the lock is mined", + ) + .into(), )); } l.listing_file_json.ok_or_else(|| { diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index 925abac..a1eb11f 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -912,6 +912,24 @@ pub fn get_wallet_profile( Ok(profile) } +/// The `Network` of one *named* profile, or an error when the profile is +/// missing or its stored string does not parse. For user-triggered commands, +/// read models and background jobs, where guessing mainnet would compute the +/// wrong answer (a balance with the wrong coinbase maturity, a renewal window +/// aged by a wall clock regtest does not keep, a listing judged on another +/// chain's lockup) and CODING_STANDARDS says a DB failure is returned, not +/// swallowed. Here, below `commands`, so the commands +/// (`commands::active_profile::profile_network_from_conn`) and the sync jobs +/// share one implementation. +pub fn profile_network( + conn: &rusqlite::Connection, + profile_id: &str, +) -> Result { + let profile = get_wallet_profile(conn, profile_id)? + .ok_or_else(|| AppError::NotFound(format!("wallet profile {profile_id}")))?; + crate::noncustodial::derivation::network_from_profile(&profile.network) +} + /// List all wallet profiles, newest first. pub fn list_wallet_profiles( conn: &rusqlite::Connection, @@ -1279,27 +1297,33 @@ pub fn never_sent(status: &str) -> bool { /// The status of a draft the chain has mined. const CONFIRMED_STATUS: &str = "confirmed"; -/// Whether a lock TRANSFER draft in `status` still holds its name for a -/// Locking listing (R27): it may yet be sent ([`never_sent`]), or it was -/// sent and not given up ([`may_have_reached_chain`]). A dropped, failed or -/// deleted lock draft does not. -pub fn lock_draft_holds_name(status: &str) -> bool { +/// Rule one: whether a draft in `status` is alive — it may yet be sent +/// ([`never_sent`]), or it was sent and not given up +/// ([`may_have_reached_chain`]). A `dropped` or `failed` draft, or one whose +/// row is gone, is dead: it holds nothing (a Locking listing's name, R27) and +/// its transaction is read from the chain alone (R19). +pub fn draft_alive(status: &str) -> bool { never_sent(status) || may_have_reached_chain(status) } -/// Whether a lock TRANSFER draft in `status` may still land on chain: it -/// holds the name ([`lock_draft_holds_name`]) and is not mined yet. Until it -/// is mined or given up, a missing lock coin proves nothing (R19). -pub fn lock_draft_may_still_land(status: &str) -> bool { - draft_may_still_land(status) +/// The draft statuses [`draft_alive`] accepts, as an SQL list, for the +/// queries that ask the same question of the database. +pub fn alive_sql() -> String { + sql_list( + UNSENT_STATUSES + .iter() + .chain(REACHED_CHAIN_STATUSES.iter()) + .copied(), + ) } -/// Whether a draft in `status` may still be mined: unsent ([`never_sent`]), -/// or sent and neither mined nor given up (`dropped`, `failed`). A Cancel -/// transfer in such a status holds back the FINALIZE into the lock, which -/// spends the same coin. +/// Rule two: whether a draft in `status` may still be mined — alive +/// ([`draft_alive`]) and not mined yet. Until a lock TRANSFER is mined or +/// given up, a missing lock coin proves nothing (R19); a Cancel transfer in +/// such a status holds back the FINALIZE into the lock, which spends the +/// same coin. pub fn draft_may_still_land(status: &str) -> bool { - (never_sent(status) || may_have_reached_chain(status)) && status != CONFIRMED_STATUS + draft_alive(status) && status != CONFIRMED_STATUS } /// `items` quoted as an SQL list, `('a', 'b')`. Only for the fixed spellings @@ -1532,6 +1556,31 @@ impl ListingState { pub fn cancel_abortable_sql() -> String { sql_list(Self::CANCEL_ABORTABLE.iter().map(|s| s.as_str())) } + + /// The states the before-lock job reads + /// ([`list_shakedex_listings_before_lock`]): [`Self::CANCEL_ABORTABLE`], + /// and Aborted within the re-check window. + 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 whose listing file may leave the wallet (R23): its + /// FINALIZE into the lock is mined and the listing is not over. Before + /// that the steps are over a coin that may never exist; Sold, Cancelled, + /// Aborted, Expired and Restored listings are refused with their own + /// reason. + pub const EXPORT_ALLOWED: [ListingState; 5] = [ + Self::Listed, + Self::SalePending, + Self::Cancelling, + Self::CancelAwaitingFinalize, + Self::CancelFinalizing, + ]; } impl std::str::FromStr for ListingState { @@ -1760,7 +1809,7 @@ pub fn open_shakedex_listing_for_name( /// 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 ([`lock_draft_holds_name`]): a dropped, +/// lock TRANSFER draft is alive ([`draft_alive`]): a dropped, /// failed or deleted lock draft does not freeze the name; the chain refresh /// (T4) resolves the row, and a TRANSFER mined after all shows as a pending /// transfer that Cancel transfer handles. @@ -1776,7 +1825,7 @@ pub fn listing_blocking_owner_actions( return Ok(Some(listing)); } let status = lock_draft_status(conn, &listing)?; - let alive = status.is_some_and(|s| lock_draft_holds_name(&s)); + let alive = status.is_some_and(|s| draft_alive(&s)); Ok(alive.then_some(listing)) } @@ -2067,14 +2116,12 @@ pub fn list_shakedex_listings_finalizing( ) -> Result, AppError> { let sql = format!( "SELECT {SHAKEDEX_LISTING_COLS} FROM shakedex_listings - WHERE wallet_profile_id = ?1 AND state IN (?2, ?3) - ORDER BY created_at, id" + WHERE wallet_profile_id = ?1 AND state IN {} + ORDER BY created_at, id", + sql_list(ListingState::FINALIZE_JOB.iter().map(|s| s.as_str())) ); let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map( - params![profile_id, ListingState::Finalizing, ListingState::Listed], - row_to_shakedex_listing, - )?; + let rows = stmt.query_map(params![profile_id], row_to_shakedex_listing)?; Ok(rows.collect::, _>>()?) } diff --git a/src-tauri/src/noncustodial/shakedex/sell.rs b/src-tauri/src/noncustodial/shakedex/sell.rs index 72b364e..db5ff2e 100644 --- a/src-tauri/src/noncustodial/shakedex/sell.rs +++ b/src-tauri/src/noncustodial/shakedex/sell.rs @@ -183,6 +183,36 @@ pub fn expires_before_the_lock(name: &str, expiry_end: i64) -> AppError { )) } +/// R18/R19: whether the lock TRANSFER `(lock_transfer_txid, 0)` owns the +/// name, from hsd's `getnameinfo`: `info.owner` is that outpoint and +/// `info.revoked` is 0 (a REVOKE leaves `owner` at the coin it spent and +/// sets `revoked`, hsd `chain.js`). Finalize & sign and the sync job ask it +/// the same way. +pub fn lock_transfer_owns_name( + owner_hash: &str, + owner_index: u64, + revoked: u64, + lock_transfer_txid: &str, +) -> bool { + revoked == 0 && owner_index == 0 && owner_hash.eq_ignore_ascii_case(lock_transfer_txid) +} + +/// The block of the name's TRANSFER, hsd's `info.transfer` in a +/// `getnameinfo` reply's `info`. Finalize & sign and the sync job both take +/// the lockup from this fact, not from the TRANSFER coin's height: it is the +/// field hsd's FINALIZE rule reads (`chain.js`, `height < ns.transfer + +/// transferLockup`), set when the block holding the TRANSFER is connected. +/// `None` when the reply leaves it out, gives a number no block can have +/// (above `i64::MAX`), or says 0, which in hsd means "no TRANSFER" (set on +/// FINALIZE, UPDATE and REVOKE): with our TRANSFER the owner, not hsd's +/// whole answer. +pub fn transfer_height(info: &serde_json::Value) -> Option { + info.get("transfer") + .and_then(serde_json::Value::as_u64) + .and_then(|t| i64::try_from(t).ok()) + .filter(|t| *t > 0) +} + /// `wallet_tx_drafts.action` of a draft finalizing our name into its lock. pub const LOCK_FINALIZE_ACTION: &str = "shakedex_lock_finalize"; @@ -301,6 +331,7 @@ pub fn parse_step_price(text: &str) -> Result { if t.is_empty() { return Err(bad("enter a price in HNS".into())); } + // No '.' is a whole number of HNS: no decimals, not an error. let (whole, frac) = t.split_once('.').unwrap_or((t, "")); let digits = |s: &str| s.chars().all(|c| c.is_ascii_digit()); if (whole.is_empty() && frac.is_empty()) || !digits(whole) || !digits(frac) { diff --git a/src-tauri/src/shakedex_jobs.rs b/src-tauri/src/shakedex_jobs.rs index 529e14a..97cd138 100644 --- a/src-tauri/src/shakedex_jobs.rs +++ b/src-tauri/src/shakedex_jobs.rs @@ -49,13 +49,13 @@ use crate::db::queries::{self, PurchaseProgress, PurchaseState, ShakedexPurchase, TxDraftRow}; use crate::error::AppError; -use crate::noncustodial::derivation; use crate::noncustodial::network::Network; use crate::noncustodial::node_rpc::NodeRpc; use crate::noncustodial::rpc::{self, NodeRpcClient}; use crate::noncustodial::send::RESERVATION_TTL_SECS; 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::tx_evidence; @@ -848,10 +848,10 @@ enum LockOnChain { /// hsd reports no live state for the name (`info: null`): it never /// existed or has expired. NoName, - /// The lock TRANSFER is the owner of a name that is not revoked. - /// `transfer` is hsd's `info.transfer`, the block of the TRANSFER; - /// `None` when the reply leaves it out or says 0 (no TRANSFER, which - /// cannot be while our TRANSFER owns the name: not hsd's whole answer). + /// The lock TRANSFER owns a name that is not revoked + /// ([`sell::lock_transfer_owns_name`]). `transfer` is + /// [`sell::transfer_height`]: `None` when hsd's `info.transfer` is + /// missing, out of range or 0 (not hsd's whole answer). Owner { transfer: Option }, /// The lock TRANSFER is a coin in the mempool (`height: -1`), not the /// owner yet (hsd moves `owner` only when a block is connected). @@ -887,13 +887,10 @@ async fn lock_on_chain( "node did not report the name's owner or whether it was revoked".into(), )); }; - let transfer = info - .get("transfer") - .and_then(|t| t.as_u64()) - .and_then(|t| i64::try_from(t).ok()) - .filter(|t| *t > 0); - if revoked == 0 && index == 0 && hash.eq_ignore_ascii_case(lock_transfer_txid) { - Ok(LockOnChain::Owner { transfer }) + if sell::lock_transfer_owns_name(hash, u64::from(index), revoked, lock_transfer_txid) { + Ok(LockOnChain::Owner { + transfer: sell::transfer_height(info), + }) } 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. @@ -1030,7 +1027,7 @@ pub async fn refresh_listings_before_lock_with_client( if listings.is_empty() { return Ok(()); } - let network = profile_network(conn, profile_id)?; + 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); @@ -1039,13 +1036,6 @@ pub async fn refresh_listings_before_lock_with_client( Ok(()) } -/// The network of profile `profile_id`. -fn profile_network(conn: &rusqlite::Connection, profile_id: &str) -> Result { - let profile = queries::get_wallet_profile(conn, profile_id)? - .ok_or_else(|| AppError::NotFound(format!("wallet profile {profile_id}")))?; - derivation::network_from_profile(&profile.network) -} - async fn refresh_before_lock( conn: &rusqlite::Connection, client: &dyn NodeRpc, @@ -1097,7 +1087,7 @@ async fn refresh_before_lock( } } let status = queries::lock_draft_status(conn, l)?; - if !status.is_some_and(|s| queries::lock_draft_may_still_land(&s)) { + if !status.is_some_and(|s| queries::draft_may_still_land(&s)) { queries::abort_shakedex_listing(conn, &l.id)?; } } @@ -1187,7 +1177,7 @@ pub async fn refresh_lock_finalize_with_client( if listings.is_empty() { return Ok(()); } - let network = profile_network(conn, profile_id)?; + let network = queries::profile_network(conn, profile_id)?; for l in listings { if let Err(e) = refresh_lock_finalize(conn, client, network, &l).await { eprintln!("shakedex listings: {} ({}): {e}", l.id, l.name); @@ -1246,8 +1236,7 @@ async fn finalize_never_landed( Some(id) => queries::get_tx_draft(conn, id)?.map(|d| d.status), None => None, }; - let dead = - status.is_none_or(|s| !(queries::never_sent(&s) || queries::may_have_reached_chain(&s))); + let dead = !status.is_some_and(|s| queries::draft_alive(&s)); let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() else { return Ok(false); }; diff --git a/src-tauri/src/tests/shakedex_layering_tests.rs b/src-tauri/src/tests/shakedex_layering_tests.rs index 1892a7a..b4662e2 100644 --- a/src-tauri/src/tests/shakedex_layering_tests.rs +++ b/src-tauri/src/tests/shakedex_layering_tests.rs @@ -19,10 +19,10 @@ const SOURCES: [(&str, &str); 3] = [ /// The only command modules these sources may name: the shared draft /// context, and the secure window's confirm helper (`secure_confirm`, the -/// one place that names `secure_prompt` for them). Everything else under `commands` is a sibling, and the legacy -/// shared layers (`sync`, `read`, `namebase`, `secure_prompt`) are siblings -/// too — refusing the whole class keeps a new sibling from slipping past a -/// list of known ones. +/// one place that names `secure_prompt` for them). Everything else under +/// `commands` is a sibling, and the legacy shared layers (`sync`, `read`, +/// `namebase`, `secure_prompt`) are siblings too — refusing the whole class +/// keeps a new sibling from slipping past a list of known ones. const ALLOWED: [&str; 2] = ["draft_ctx", "secure_confirm"]; /// Code only: a mention in a comment is not an import, and the unit tests at diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index 389b32a..c02b998 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -186,12 +186,12 @@ fn cancel_abortable_sql_lists_the_rust_states() { } } -/// The draft-status sets the listings read: a lock draft holds its name -/// while it is unsent or may have reached the chain, and may still land -/// until it is mined; `dropped` and `failed` do neither. +/// The two draft-status rules the listings read: a draft is alive while it +/// is unsent or may have reached the chain, and may still land until it is +/// mined; `dropped` and `failed` are neither. The SQL list is the Rust rule. #[test] fn draft_status_sets_partition_the_statuses() { - for (status, unsent, holds, may_land) in [ + for (status, unsent, alive, may_land) in [ ("draft", true, true, true), ("signed", true, true, true), ("broadcast_pending", false, true, true), @@ -201,10 +201,10 @@ fn draft_status_sets_partition_the_statuses() { ("failed", false, false, false), ] { assert_eq!(queries::never_sent(status), unsent, "{status}"); - assert_eq!(queries::lock_draft_holds_name(status), holds, "{status}"); + assert_eq!(queries::draft_alive(status), alive, "{status}"); assert_eq!( - queries::lock_draft_may_still_land(status), - may_land, + queries::alive_sql().contains(&format!("'{status}'")), + alive, "{status}" ); assert_eq!(queries::draft_may_still_land(status), may_land, "{status}"); @@ -572,6 +572,50 @@ fn finalized_elsewhere_is_adopted_only_before_the_lock() { } } +/// 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. +#[test] +fn job_listing_sets_are_disjoint() { + let before = ListingState::BEFORE_LOCK_JOB; + let fin = ListingState::FINALIZE_JOB; + for s in before { + assert!(!fin.contains(&s), "{s:?} in both jobs"); + } + assert!(!before.contains(&ListingState::Restored)); + assert!(!fin.contains(&ListingState::Restored)); + for s in ListingState::CANCEL_ABORTABLE { + assert!(before.contains(&s), "{s:?}"); + } + + let conn = store_conn(); + for (i, state) in ListingState::ALL.into_iter().enumerate() { + queries::insert_shakedex_listing( + &conn, + &listing(&format!("l{i}"), &format!("n{i}"), state), + ) + .unwrap(); + } + let states = |ls: Vec| { + let mut v: Vec<&str> = ls.into_iter().map(|l| l.state.as_str()).collect(); + v.sort_unstable(); + v + }; + let sorted = |set: &[ListingState]| { + let mut v: Vec<&str> = set.iter().map(|s| s.as_str()).collect(); + v.sort_unstable(); + v + }; + assert_eq!( + states(queries::list_shakedex_listings_before_lock(&conn, STORE_PROFILE, 7).unwrap()), + sorted(&before) + ); + assert_eq!( + states(queries::list_shakedex_listings_finalizing(&conn, STORE_PROFILE).unwrap()), + sorted(&fin) + ); +} + /// The Finalizing/Listed source lists exactly those two states of a profile. #[test] fn finalizing_and_listed_listings_are_listed() { @@ -2283,13 +2327,13 @@ async fn refuses_finalize_on_commitment_mismatch() { } /// R19/R18: the name must still be held by our lock TRANSFER (the owner, -/// output 0, of an unrevoked name), and the TRANSFER must be mined and -/// unspent; each missing field is "could not check". +/// output 0, of an unrevoked name), unspent, with hsd's TRANSFER block +/// (`info.transfer`, the lockup's start); each missing field is "could not +/// check". #[tokio::test] async fn finalize_and_sign_refused_when_the_name_left_its_lock_transfer() { let mut r = ready_fixture("regtest").await; let net = Network::Regtest; - let lock_txid = r.lock_txid.clone(); let coin = r.coin(); let mut moved = locked_name_info(RENEWAL, &"77".repeat(32)); moved["info"]["transfer"] = 0.into(); @@ -2311,9 +2355,15 @@ async fn finalize_and_sign_refused_when_the_name_left_its_lock_transfer() { .remove("revoked"); let mut no_info = r.info(); no_info.as_object_mut().unwrap().remove("info"); - let mut no_height = coin.clone(); - no_height.as_object_mut().unwrap().remove("height"); - let mempool = lock_transfer_coin(net, &lock_txid, &r.key().program, -1); + let mut no_transfer = r.info(); + no_transfer["info"] + .as_object_mut() + .unwrap() + .remove("transfer"); + let mut zero_transfer = r.info(); + zero_transfer["info"]["transfer"] = 0.into(); + let mut huge_transfer = r.info(); + huge_transfer["info"]["transfer"] = u64::MAX.into(); for (case, info, coin, needle) in [ ( "owner moved", @@ -2348,11 +2398,22 @@ async fn finalize_and_sign_refused_when_the_name_left_its_lock_transfer() { ), ("no info", no_info, Some(coin.clone()), "could not check"), ("coin spent", r.info(), None, "no longer unspent"), - ("in the mempool", r.info(), Some(mempool), "not mined"), ( - "coin without a height", - r.info(), - Some(no_height), + "no TRANSFER block", + no_transfer, + Some(coin.clone()), + "could not check", + ), + ( + "TRANSFER block 0", + zero_transfer, + Some(coin.clone()), + "could not check", + ), + ( + "TRANSFER block out of range", + huge_transfer, + Some(coin.clone()), "could not check", ), ] { @@ -2428,7 +2489,8 @@ async fn finalize_and_sign_refused_when_the_name_left_its_lock_transfer() { /// R19: Finalize & sign opens once the transfer lockup is over at the next /// block (blocks_until_finalize == 0), and a listing still Locking is -/// refused whatever the node says. +/// refused whatever the node says; one already past it, or over, is refused +/// with its state's own reason. #[tokio::test] async fn finalize_and_sign_refused_before_the_lockup() { let mut r = ready_fixture("regtest").await; @@ -2450,6 +2512,13 @@ async fn finalize_and_sign_refused_before_the_lockup() { ("locking", "after the transfer lockup"), ("finalizing", "already finalized into its lock"), ("listed", "already finalized into its lock"), + ("sale_pending", "already finalized into its lock"), + ("cancelling", "already finalized into its lock"), + ("aborted", "this listing was aborted"), + ("expired", "the name expired while it was locked"), + ("cancelled", "this listing is cancelled"), + ("sold", "this name is sold"), + ("restored", "finalized by another wallet"), ] { with_db(&r.app, |c| { c.execute( @@ -3100,7 +3169,30 @@ async fn listing_file_is_exported_only_once_the_finalize_is_mined() { set_state(ListingState::Listed); let file = export().expect("Listed"); let saved = r.listing().listing_file_json; - assert_eq!(Some(file), saved); + assert_eq!(Some(file.clone()), saved); + // Every state: the file once the FINALIZE is mined and the listing is + // not over; each ended state with its own reason. + for state in ListingState::ALL { + set_state(state); + let want = match state { + ListingState::Aborted => Some("this listing was aborted"), + ListingState::Expired => Some("the name expired while it was locked"), + ListingState::Cancelled => Some("this listing is cancelled"), + ListingState::Sold => Some("this name is sold"), + ListingState::Restored => Some("finalized by another wallet"), + ListingState::Locking | ListingState::ReadyToFinalize | ListingState::Finalizing => { + Some("once the finalize into the lock is mined") + } + _ => None, + }; + match want { + None => assert_eq!(export().expect("exported"), file, "{state:?}"), + Some(needle) => { + let e = err_text(export().expect_err("refused")); + assert!(e.contains(needle), "{state:?}: {e}"); + } + } + } let e = with_db(&r.app, |c| { export_listing_file_from_conn(c, "another-profile", &r.listing_id) }) From 07f54ef379d2617582284851faded7b6d7a974be Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 17:06:22 +0200 Subject: [PATCH 13/17] fix(shakedex): settle a listing whose dead FINALIZE lost its lock TRANSFER to something else A Finalizing listing whose FINALIZE draft was failed, dropped or deleted, and whose lock TRANSFER had been spent by something other than that FINALIZE (an older Cancel transfer mined later, another device's FINALIZE into our lock, a REVOKE), stayed Finalizing for ever: its lock coin and its lock TRANSFER were both hsd's 404 and no job read it again. The finalize job now settles it with the same code as the before-lock job (`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 is no verdict. The Aborted, Expired and Restored writes accept a Finalizing listing only while its FINALIZE draft is dead, and drop that FINALIZE's outpoint, steps and file, so a reorg that relocks the listing does not carry them. The jobs' listing sets stay disjoint. --- docs/specs/2026-10-05-shakedex-name-sales.md | 4 +- src-tauri/src/db/queries.rs | 51 +++- src-tauri/src/shakedex_jobs.rs | 127 ++++++---- src-tauri/src/tests/shakedex_sell_tests.rs | 239 ++++++++++++++++++- 4 files changed, 361 insertions(+), 60 deletions(-) diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index 51d9411..28fa4f7 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 at signing minus 512 s, so it is valid at once by R3; it is taken when the steps are signed, 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`), `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, while the lock TRANSFER is a coin again, returns it to ReadyToFinalize without its lock outpoint, steps and file. The two jobs never take the same listing (Locking, ReadyToFinalize and Aborted listings against Finalizing and Listed ones), 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}`, `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`, `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`. +*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`), `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`, `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`. **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`); nothing is signed or written unless it is confirmed. diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index a1eb11f..d354f59 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -1910,15 +1910,17 @@ pub fn list_shakedex_listings_before_lock( /// R19: the name left the lock TRANSFER before the FINALIZE into the lock /// (a mined Cancel transfer, a REVOKE, or a lock TRANSFER that never /// landed), so the listing is Aborted. Only a listing whose abort is still -/// the Cancel transfer moves. Returns how many rows changed (0 or 1). +/// 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) } /// hsd reports no live state for the name (`getnameinfo` with `info: null`) /// before the FINALIZE into the lock, so the listing is Expired. Only a -/// listing whose abort is still the Cancel transfer moves. Returns how many -/// rows changed (0 or 1). +/// listing whose abort is still 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 expire_shakedex_listing(conn: &rusqlite::Connection, id: &str) -> Result { end_listing_before_lock(conn, id, ListingState::Expired) } @@ -1929,13 +1931,39 @@ fn end_listing_before_lock( to: ListingState, ) -> Result { let sql = format!( - "UPDATE shakedex_listings SET state = ?2, updated_at = datetime('now') - WHERE id = ?1 AND state IN {}", - ListingState::cancel_abortable_sql() + "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() ); Ok(conn.execute(&sql, params![id, to])?) } +/// 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 { + 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() + ) +} + +/// What a listing ended from Finalizing loses of its dead FINALIZE: the +/// draft link, the steps and file signed over a lock coin that never +/// existed, and their expiry. Already so for a listing before the lock. +const DROP_DEAD_FINALIZE_SET: &str = "lock_finalize_draft_id = NULL, steps_json = '[]', \ + listing_file_json = NULL, expires_at = NULL"; + /// R19: a reorg took an Aborted listing's abort out of the chain (its lock /// TRANSFER is a coin or the owner again), so the listing is Locking again — unless another listing of the name is /// open by now (`idx_shakedex_listings_open_name` allows one): that newer @@ -2087,7 +2115,9 @@ pub fn mark_listing_finalizing_again( /// R19 (coordinator (b)): the name was finalized into this listing's lock by /// a FINALIZE this device did not build (another device with the same seed): /// the listing tracks that lock coin as a Restored lock, never Aborted. Only -/// from `CANCEL_ABORTABLE`. Returns how many rows changed (0 or 1). +/// from `CANCEL_ABORTABLE`, or from Finalizing once its own FINALIZE is dead +/// ([`ends_before_lock_sql`]), whose outpoint, steps and file it drops. +/// Returns how many rows changed (0 or 1). pub fn adopt_lock_finalized_elsewhere( conn: &rusqlite::Connection, id: &str, @@ -2097,9 +2127,10 @@ pub fn adopt_lock_finalized_elsewhere( let sql = format!( "UPDATE shakedex_listings SET state = ?2, lock_txid = ?3, lock_vout = ?4, abort_draft_id = NULL, - abort_txid = NULL, updated_at = datetime('now') - WHERE id = ?1 AND state IN {}", - ListingState::cancel_abortable_sql() + abort_txid = NULL, {}, updated_at = datetime('now') + WHERE id = ?1 AND {}", + DROP_DEAD_FINALIZE_SET, + ends_before_lock_sql() ); Ok(conn.execute( &sql, diff --git a/src-tauri/src/shakedex_jobs.rs b/src-tauri/src/shakedex_jobs.rs index 97cd138..75dc769 100644 --- a/src-tauri/src/shakedex_jobs.rs +++ b/src-tauri/src/shakedex_jobs.rs @@ -43,9 +43,10 @@ //! 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 -//! ([`refresh_lock_finalize_with_client`]). Both only read the node, and -//! never take the same listing. +//! 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. use crate::db::queries::{self, PurchaseProgress, PurchaseState, ShakedexPurchase, TxDraftRow}; use crate::error::AppError; @@ -1064,32 +1065,8 @@ async fn refresh_before_lock( } } match lock_on_chain(client, &l.name, lock_transfer_txid).await? { - LockOnChain::NoName if abortable => { - queries::expire_shakedex_listing(conn, &l.id)?; - } - LockOnChain::Gone { owner, revoked } if abortable => { - // Coordinator (b): a name finalized into our own lock is not an - // abort, whoever sent the FINALIZE; only positive evidence (the - // owner coin a FINALIZE at this listing's lock address) says so. - if !revoked { - match owner_in_our_lock(client, network, l, &owner).await? { - InOurLock::Finalize => { - queries::adopt_lock_finalized_elsewhere(conn, &l.id, &owner.0, owner.1)?; - return Ok(()); - } - // At our lock but not a FINALIZE: the name went through - // our lock; T4 decides. - InOurLock::Other => return Ok(()), - // The owner coin is spent in the mempool: no verdict - // until that spend is mined. - InOurLock::SpentInMempool => return Ok(()), - InOurLock::No => {} - } - } - let status = queries::lock_draft_status(conn, l)?; - if !status.is_some_and(|s| queries::draft_may_still_land(&s)) { - queries::abort_shakedex_listing(conn, &l.id)?; - } + lock @ (LockOnChain::NoName | LockOnChain::Gone { .. }) if abortable => { + settle_left_lock(conn, client, network, l, lock).await?; } LockOnChain::Owner { .. } | LockOnChain::Pending if aborted => relock(conn, l)?, LockOnChain::Owner { @@ -1117,6 +1094,52 @@ async fn refresh_before_lock( Ok(()) } +/// R19: the name has left the lock TRANSFER (`lock` is +/// [`LockOnChain::NoName`] or [`LockOnChain::Gone`]); settle listing `l` +/// from where it went. The one resolution for a listing before the lock and +/// 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; +/// - 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. +async fn settle_left_lock( + conn: &rusqlite::Connection, + client: &dyn NodeRpc, + network: Network, + l: &queries::ShakedexListing, + lock: LockOnChain, +) -> Result<(), AppError> { + let (owner, revoked) = match lock { + LockOnChain::NoName => { + queries::expire_shakedex_listing(conn, &l.id)?; + return Ok(()); + } + LockOnChain::Gone { owner, revoked } => (owner, revoked), + LockOnChain::Owner { .. } | LockOnChain::Pending => return Ok(()), + }; + if !revoked { + match owner_in_our_lock(client, network, l, &owner).await? { + InOurLock::Finalize => { + queries::adopt_lock_finalized_elsewhere(conn, &l.id, &owner.0, owner.1)?; + return Ok(()); + } + InOurLock::Other | InOurLock::SpentInMempool => return Ok(()), + InOurLock::No => {} + } + } + let status = queries::lock_draft_status(conn, l)?; + if !status.is_some_and(|s| queries::draft_may_still_land(&s)) { + queries::abort_shakedex_listing(conn, &l.id)?; + } + Ok(()) +} + fn relock(conn: &rusqlite::Connection, l: &queries::ShakedexListing) -> Result<(), AppError> { if queries::unabort_shakedex_listing(conn, &l.id)? == 0 { eprintln!( @@ -1159,9 +1182,12 @@ pub async fn refresh_lock_finalize_step(db_path: &str, profile_id: &str) { /// - 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, while the lock TRANSFER `(lock_transfer_txid, 0)` is -/// a coin again → ReadyToFinalize, its lock outpoint, steps and file -/// dropped (they were signed over a coin that does not exist); +/// `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. @@ -1198,10 +1224,22 @@ async fn refresh_lock_finalize( 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_never_landed(conn, client, l).await? - { - queries::revert_listing_to_ready(conn, &l.id)?; + 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(()); }; @@ -1223,22 +1261,17 @@ async fn refresh_lock_finalize( Ok(()) } -/// Positive evidence that a Finalizing listing's FINALIZE never landed: its -/// draft can no longer be mined (`failed`, `dropped`, or gone) and the lock -/// TRANSFER it spends is a coin again (hsd's `GET /coin` answers 404 for a -/// coin spent in a block or in the mempool). -async fn finalize_never_landed( +/// 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 +/// ([`queries::abort_shakedex_listing`]). +fn finalize_dead( conn: &rusqlite::Connection, - client: &dyn NodeRpc, l: &queries::ShakedexListing, ) -> Result { let status = match l.lock_finalize_draft_id.as_deref() { Some(id) => queries::get_tx_draft(conn, id)?.map(|d| d.status), None => None, }; - let dead = !status.is_some_and(|s| queries::draft_alive(&s)); - let Some(lock_transfer_txid) = l.lock_transfer_txid.as_deref() else { - return Ok(false); - }; - Ok(dead && client.get_coin(lock_transfer_txid, 0).await?.is_some()) + Ok(!status.is_some_and(|s| queries::draft_alive(&s))) } diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index c02b998..a5fd11f 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -548,7 +548,9 @@ fn listing_transitions_write_only_from_their_previous_state() { } /// A FINALIZE into our lock from another device: only a listing whose abort -/// is still the Cancel transfer adopts the coin, as Restored. +/// is still the Cancel transfer adopts the coin, as Restored (a Finalizing +/// one only once its own FINALIZE is dead, +/// `a_dead_finalize_ends_its_listing_like_before_the_lock`). #[test] fn finalized_elsewhere_is_adopted_only_before_the_lock() { for state in ListingState::ALL { @@ -556,6 +558,9 @@ fn finalized_elsewhere_is_adopted_only_before_the_lock() { let mut l = listing("l1", "dexsale", state); l.abort_draft_id = Some("cancel".into()); l.abort_txid = Some("cd".repeat(32)); + // A Finalizing listing's own FINALIZE, still in flight. + insert_draft(&conn, "fin", sell::LOCK_FINALIZE_ACTION, "broadcasted"); + l.lock_finalize_draft_id = Some("fin".into()); queries::insert_shakedex_listing(&conn, &l).unwrap(); let n = queries::adopt_lock_finalized_elsewhere(&conn, "l1", &"ab".repeat(32), 0).unwrap(); let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); @@ -572,6 +577,88 @@ fn finalized_elsewhere_is_adopted_only_before_the_lock() { } } +/// R19, coordinator ruling: a Finalizing listing whose FINALIZE draft is +/// dead (`failed`, `dropped`, or gone) ends like a listing before the lock — +/// Aborted, Expired, or a Restored lock — and loses its dead FINALIZE's +/// outpoint, steps and file. While that draft is alive (unsent, sent, or +/// mined) none of the three writes it. +#[test] +fn a_dead_finalize_ends_its_listing_like_before_the_lock() { + type End = fn(&Connection, &str) -> Result; + let adopt: End = |c, id| queries::adopt_lock_finalized_elsewhere(c, id, &"ad".repeat(32), 2); + let ends: [(&str, End, ListingState); 3] = [ + ( + "abort", + queries::abort_shakedex_listing, + ListingState::Aborted, + ), + ( + "expire", + queries::expire_shakedex_listing, + ListingState::Expired, + ), + ("adopt", adopt, ListingState::Restored), + ]; + for (label, end, to) in ends { + for (status, dead) in [ + (None, true), + (Some("failed"), true), + (Some("dropped"), true), + (Some("draft"), false), + (Some("signed"), false), + (Some("broadcast_pending"), false), + (Some("broadcasted"), false), + (Some("confirmed"), false), + ] { + let case = format!("{label}, FINALIZE {status:?}"); + let conn = store_conn(); + queries::insert_shakedex_listing( + &conn, + &listing("l1", "dexsale", ListingState::ReadyToFinalize), + ) + .unwrap(); + insert_draft(&conn, "fin", sell::LOCK_FINALIZE_ACTION, "signed"); + assert_eq!(finalizing(&conn, "l1", "fin"), 1); + match status { + Some(st) => insert_draft_status(&conn, "fin", st), + None => { + conn.execute("DELETE FROM wallet_tx_drafts WHERE id = 'fin'", []) + .unwrap(); + } + } + let n = end(&conn, "l1").unwrap(); + let l = queries::get_shakedex_listing(&conn, "l1").unwrap().unwrap(); + if dead { + assert_eq!((n, l.state), (1, to), "{case}"); + assert_eq!(l.lock_finalize_draft_id, None, "{case}"); + assert_eq!( + (l.steps_json.as_str(), l.listing_file_json, l.expires_at), + ("[]", None, None), + "{case}" + ); + let want = (to == ListingState::Restored).then(|| ("ad".repeat(32), 2)); + assert_eq!( + l.lock_txid.zip(l.lock_vout), + want, + "{case}: only the adopted outpoint" + ); + } else { + assert_eq!((n, l.state), (0, ListingState::Finalizing), "{case}"); + assert_eq!(l.lock_txid, Some("f1".repeat(32)), "{case}"); + assert_ne!(l.steps_json, "[]", "{case}"); + } + } + } +} + +fn insert_draft_status(conn: &Connection, id: &str, status: &str) { + conn.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = ?2", + params![status, id], + ) + .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. @@ -3975,3 +4062,153 @@ async fn mined_lock_transfer_that_is_not_the_owner_changes_nothing() { assert_eq!(listing_state(&app, &id), state, "{state:?}: no height"); } } + +/// 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 +/// covenant `cov`. +fn left_the_lock(info: Value, owner: Option<(&str, u32, &str, u8)>) -> MockNodeRpc { + let coins = owner + .map(|(txid, vout, address, cov)| coin_at(txid, vout, address, cov, QUIET_TIP - 1)) + .into_iter() + .collect(); + chain_at(info, ready_tip(Network::Regtest) + 5, coins) +} + +/// A Finalizing listing whose FINALIZE draft has status `status` (`gone`: +/// deleted), its lock TRANSFER mined and its draft confirmed. +async fn finalizing_with(status: &str) -> Ready { + let r = ready_fixture("regtest").await; + let (fin, _) = 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| { + let s = if status == "gone" { "failed" } else { status }; + if s != "signed" { + queries::update_tx_draft_status(c, &fin_draft, s, None, Some(&fin)).unwrap(); + } + if status == "gone" { + queries::delete_tx_draft(c, &fin_draft).unwrap(); + } + }); + r +} + +/// R19, coordinator ruling: our FINALIZE is dead (`failed`, `dropped` or +/// gone) and the lock TRANSFER is hsd's 404 — something else spent it. The +/// listing is resolved as before the lock: an older Cancel transfer mined +/// later, or a REVOKE → Aborted; another device's FINALIZE into our lock → +/// Restored with that outpoint; `info: null` → Expired; the owner coin hsd's +/// 404 → no verdict. A FINALIZE still in flight changes nothing. +#[tokio::test] +async fn dead_finalize_with_its_lock_transfer_spent_elsewhere_is_resolved_from_the_name() { + let net = Network::Regtest; + let (ours, lock) = (addr00(net).0, lock_address(net)); + let cancel = "ce".repeat(32); + let other_fin = "0f".repeat(32); + for status in ["failed", "dropped", "gone"] { + // An older Cancel transfer mined after all: the owner is its UPDATE. + let r = finalizing_with(status).await; + let chain = left_the_lock( + info_with(&cancel, 0, 0), + Some((&cancel, 0, &ours, COV_UPDATE)), + ); + run_listing_jobs(&r.app, &chain).await; + let l = r.listing(); + assert_eq!(l.state, ListingState::Aborted, "cancel mined, {status}"); + assert_eq!( + (l.lock_txid, l.lock_finalize_draft_id, l.steps_json.as_str()), + (None, None, "[]"), + "cancel mined, {status}: the dead FINALIZE's outpoint and steps go" + ); + } + + // Another device's FINALIZE into our lock. + let r = finalizing_with("dropped").await; + let chain = left_the_lock( + info_with(&other_fin, 0, 0), + Some((&other_fin, 0, &lock, COV_FINALIZE)), + ); + run_listing_jobs(&r.app, &chain).await; + let l = r.listing(); + assert_eq!(l.state, ListingState::Restored); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(other_fin.as_str()), Some(0)) + ); + assert_eq!( + ( + l.lock_finalize_draft_id, + l.steps_json.as_str(), + l.listing_file_json + ), + (None, "[]", None) + ); + + // 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; + 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; + assert_eq!(r.listing().state, ListingState::Expired, "info null"); + + // No verdict: the owner coin is spent in the mempool (404), our + // FINALIZE still in flight, or the lock TRANSFER still the owner (its + // coin spent in the mempool). + for (case, status, info, owner) in [ + ("owner coin 404", "failed", info_with(&cancel, 0, 0), None), + ( + "FINALIZE broadcasted", + "broadcasted", + info_with(&cancel, 0, 0), + Some((cancel.as_str(), 0, ours.as_str(), COV_UPDATE)), + ), + ( + "FINALIZE not sent", + "signed", + info_with(&cancel, 0, 0), + Some((cancel.as_str(), 0, ours.as_str(), COV_UPDATE)), + ), + ] { + let r = finalizing_with(status).await; + run_listing_jobs(&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; + assert_eq!( + r.listing().state, + ListingState::Finalizing, + "lock TRANSFER still the owner" + ); +} + +/// An abort from Finalizing that a reorg undoes: the lock TRANSFER is the +/// owner again, the listing is Locking, with nothing of the dead FINALIZE. +#[tokio::test] +async fn reorged_abort_of_a_dead_finalize_relocks_without_its_steps() { + let net = Network::Regtest; + let cancel = "ce".repeat(32); + let r = finalizing_with("failed").await; + let chain = left_the_lock( + info_with(&cancel, 0, 0), + Some((&cancel, 0, &addr00(net).0, COV_UPDATE)), + ); + run_listing_jobs(&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; + let l = r.listing(); + assert_eq!(l.state, ListingState::Locking); + assert_eq!((l.lock_txid, l.steps_json.as_str()), (None, "[]")); +} From cc81aa4a32f316cf83c9d09ce89390bceb31bd26 Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 17:12:48 +0200 Subject: [PATCH 14/17] fix(shakedex): keep reminding until the FINALIZE into the lock is sent, and judge R31 again when it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A listing whose FINALIZE was signed but never sent stopped getting its daily reminder the moment Finalize & sign ran, though nothing was locked in. The `listing_ready` reminder now also lists a Finalizing listing whose FINALIZE draft is `draft` or `signed`, and reads "Ready to finalize" / "{name}: the transfer lockup is over — Finalize & sign to lock it in and set its price". Sending a `shakedex_lock_finalize` draft runs R31 again from the node's tip with no lockup left, as Finalize & sign judged it (`draft_ctx::recheck_lock_expiry` with `LockSend::Finalize`); a refusal sends nothing and keeps the signed draft and the Finalizing listing. The lock TRANSFER keeps its own recheck, which discards its unsent draft. A shell-level test pins that the disabled setting silences the listing reminder. --- docs/specs/2026-10-05-shakedex-name-sales.md | 8 +- src-tauri/src/commands/deadlines.rs | 15 ++-- src-tauri/src/commands/draft_ctx.rs | 34 +++++--- src-tauri/src/commands/tx.rs | 48 +++++++----- src-tauri/src/db/queries.rs | 24 ++++-- src-tauri/src/tests/deadlines_cmd_tests.rs | 57 ++++++++++++-- src-tauri/src/tests/shakedex_sell_tests.rs | 82 +++++++++++++++++--- 7 files changed, 209 insertions(+), 59 deletions(-) diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index 28fa4f7..5b0d692 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 at signing minus 512 s, so it is valid at once by R3; it is taken when the steps are signed, 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`), `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`, `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`. +*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`. **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`); nothing is signed or written unless it is confirmed. @@ -158,8 +158,8 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI *Pinned:* `shakedex_sell_tests::{lock_refused_without_write_capability, lock_refused_for_ledger_and_watch_only, mainnet_lock_needs_experimental_flag, regtest_lock_ignores_flag, finalize_and_sign_ignores_experimental_flag}`, `lock-dialog.test.tsx` (disabled with the R16 reason, disabled on mainnet without the flag, no publish box on regtest). **R31 — No lock that cannot reach the market.** hsd v8.0.0 renews a name on FINALIZE (it sets the name's renewal height to the FINALIZE's block), so the FINALIZE into the lock restarts the renewal clock; the only expiry risk is the transfer lockup before it, while the name sits at the seller's address under its TRANSFER, which renews nothing. Lock, and Finalize & sign, are refused when the name's expiry end (`NameParams::expiry_end` of its renewal height, claim-aware) falls at or before `tip + 1 + remaining lockup + 1 day` — R9's margin, from the seller's side, with R9's day (144 blocks, or the transfer lockup where that is shorter). The remaining lockup is the full `transferLockup` at Lock, and at Finalize & sign `NameParams::blocks_until_finalize` of the lock TRANSFER's height (0 once the lockup has passed), because requiring a second lockup there would refuse a FINALIZE hsd accepts and that renews the name, leaving it to expire on a TRANSFER coin. Finalize & sign re-checks it because it may run days after the lock. When the expiry end is less than six months (180 such days) after the tip, the Lock dialog warns that the name must not expire before Finalize & sign is done, and that the FINALIZE into the lock renews it, so the listing itself is not cut short by this expiry. -*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 is sent (`commands/tx.rs::broadcast_tx_draft` through `commands/draft_ctx.rs::recheck_lock_expiry`, the full lockup from the tip then; a refusal discards an unsent lock draft with its listing and sends nothing); 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, 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`). +*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}`. diff --git a/src-tauri/src/commands/deadlines.rs b/src-tauri/src/commands/deadlines.rs index 26afa9f..356e8c4 100644 --- a/src-tauri/src/commands/deadlines.rs +++ b/src-tauri/src/commands/deadlines.rs @@ -14,7 +14,8 @@ //! ([`scan_purchase_deadlines`], R14). A fourth is the same kind of reminder //! for a Shakedex listing whose transfer lockup is over and that waits for //! Finalize & sign ([`scan_listing_ready_deadlines`], R19); unlike the -//! purchase one it repeats daily until Finalize & sign has run. +//! purchase one it repeats daily until Finalize & sign has run and its +//! FINALIZE is sent. //! //! The scanner core ([`scan_deadlines`]) is a PURE function: deadlines + //! config + previously-notified state → notifications to emit + new state. @@ -269,7 +270,8 @@ pub fn scan_purchase_deadlines( } /// A Shakedex listing whose transfer lockup is over: Finalize & sign can run -/// (R19). A reminder, repeated daily until it runs. +/// (R19). A reminder, repeated daily until it runs and its FINALIZE is sent +/// (`queries::list_listings_ready_to_finalize`). #[derive(Debug, Clone, PartialEq)] pub struct ListingReadyDeadline { pub wallet_profile_id: String, @@ -298,9 +300,9 @@ pub fn scan_listing_ready_deadlines( ) -> ScanResult { let ready = ready.iter().map(|d| PendingNotification { key: listing_ready_key(d, day), - title: "Ready to list".into(), + title: "Ready to finalize".into(), body: format!( - "{}: the transfer lockup is over — Finalize & sign its price to list it", + "{}: the transfer lockup is over — Finalize & sign to lock it in and set its price", d.name ), }); @@ -1044,10 +1046,11 @@ mod tests { let d1 = scan_listing_ready_deadlines(&ready, &cfg(true), &Default::default(), 20_000); assert_eq!(d1.notifications.len(), 1); assert_eq!(d1.notifications[0].key, "listing_ready:p1:dexsale:aa:20000"); - assert_eq!(d1.notifications[0].title, "Ready to list"); + assert_eq!(d1.notifications[0].title, "Ready to finalize"); assert_eq!( d1.notifications[0].body, - "dexsale: the transfer lockup is over \u{2014} Finalize & sign its price to list it" + "dexsale: the transfer lockup is over \u{2014} Finalize & sign to lock it in and set \ + its price" ); let same_day = scan_listing_ready_deadlines(&ready, &cfg(true), &d1.active_episodes, 20_000); diff --git a/src-tauri/src/commands/draft_ctx.rs b/src-tauri/src/commands/draft_ctx.rs index efa845f..7e3629a 100644 --- a/src-tauri/src/commands/draft_ctx.rs +++ b/src-tauri/src/commands/draft_ctx.rs @@ -488,15 +488,26 @@ pub(crate) async fn exclude_owner_reveal( Ok(losing) } -/// R31 again when a lock TRANSFER draft is sent: it may be broadcast days -/// after it was built, and the guard then counts the full transfer lockup -/// from the node's tip now. `Ok(Some(refusal))` when the guard refuses, with -/// the build's own sentence; an error when the node's word is missing, so -/// nothing could be checked. +/// Which lock draft R31 is judged again for at send time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LockSend { + /// The lock TRANSFER: the whole transfer lockup is still ahead. + Transfer, + /// The FINALIZE into the lock: no lockup left, it renews the name. + Finalize, +} + +/// R31 again when a lock draft is sent: it may be broadcast days after it +/// was built. The guard then counts from the node's tip now, with the full +/// transfer lockup still ahead for the lock TRANSFER and none for the +/// FINALIZE into the lock (as Finalize & sign judged it). `Ok(Some(refusal))` +/// when the guard refuses, with the build's own sentence; an error when the +/// node's word is missing, so nothing could be checked. pub(crate) async fn recheck_lock_expiry( client: &dyn NodeRpc, network: Network, name: &str, + send: LockSend, ) -> Result, AppError> { let reply = client.get_name_info(name).await?; let tip = client @@ -509,10 +520,15 @@ pub(crate) async fn recheck_lock_expiry( })? .blocks; let params = network.name_params(); - match sell::lock_expiry_guard(¶ms, &reply, tip, i64::from(params.transfer_lockup))? { - sell::ExpiryNotice::Refuse { expiry_end } => { - Ok(Some(sell::expires_before_the_lock(name, expiry_end))) - } + let remaining = match send { + LockSend::Transfer => i64::from(params.transfer_lockup), + LockSend::Finalize => 0, + }; + match sell::lock_expiry_guard(¶ms, &reply, tip, remaining)? { + sell::ExpiryNotice::Refuse { expiry_end } => Ok(Some(match send { + LockSend::Transfer => sell::expires_before_the_lock(name, expiry_end), + LockSend::Finalize => sell::expires_before_finalize(name, expiry_end), + })), sell::ExpiryNotice::Warn { .. } | sell::ExpiryNotice::Ok => Ok(None), } } diff --git a/src-tauri/src/commands/tx.rs b/src-tauri/src/commands/tx.rs index 0da47a2..717a16d 100644 --- a/src-tauri/src/commands/tx.rs +++ b/src-tauri/src/commands/tx.rs @@ -27,6 +27,7 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Runtime, State}; use crate::commands::draft_ctx::active_profile; +use crate::commands::draft_ctx::LockSend; use crate::commands::secure_prompt::{prompt_secure, SecurePromptRequest}; use crate::db; use crate::error::AppError; @@ -37,7 +38,7 @@ use crate::noncustodial::session::session_ttl_ms; use crate::noncustodial::shakedex::purchase::{ PurchaseFinalizeSummary, PurchaseSummary, PURCHASE_ACTION, PURCHASE_FINALIZE_ACTION, }; -use crate::noncustodial::shakedex::sell::LOCK_ACTION; +use crate::noncustodial::shakedex::sell::{LOCK_ACTION, LOCK_FINALIZE_ACTION}; use crate::noncustodial::tx_evidence::{ chain_evidence_with_client, node_has_tx_index_with_client, taken_by_node_with_client, ChainEvidence, Taken, NOT_TAKEN, @@ -1689,17 +1690,25 @@ pub async fn broadcast_tx_draft( }; refuse_unless_sendable(&draft.status)?; let maybe_sent = db::queries::may_have_reached_chain(&draft.status); - // A lock TRANSFER is judged by R31 again before it is sent. - let lock_name = if draft.action == LOCK_ACTION { - let summary: serde_json::Value = serde_json::from_str(&draft.summary_json)?; - Some( - summary["name"] - .as_str() - .ok_or_else(|| AppError::Other("lock draft without its name".into()))? - .to_string(), - ) - } else { - None + // A lock TRANSFER, and the FINALIZE into the lock, are judged by R31 + // again before they are sent. + let lock_send = match draft.action.as_str() { + LOCK_ACTION => Some(LockSend::Transfer), + LOCK_FINALIZE_ACTION => Some(LockSend::Finalize), + _ => None, + }; + let lock_name = match lock_send { + Some(send) => { + let summary: serde_json::Value = serde_json::from_str(&draft.summary_json)?; + Some(( + summary["name"] + .as_str() + .ok_or_else(|| AppError::Other("lock draft without its name".into()))? + .to_string(), + send, + )) + } + None => None, }; // An unsent purchase draft past its coin reservation is discarded // with its purchase, as the sync would: its coins may fund another @@ -1759,18 +1768,21 @@ pub async fn broadcast_tx_draft( } } - // R31 at send time (the lock may be sent days after it was built). A - // refusal discards an unsent draft with its Locking listing, so the name - // is free to renew; a node that cannot say leaves both, and sends nothing. - if let Some(name) = lock_name { + // R31 at send time (the lock, or its FINALIZE, may be sent days after + // it was built). A refused lock TRANSFER discards an unsent draft with + // its Locking listing, so the name is free to renew; a refused FINALIZE + // keeps its signed draft and its Finalizing listing (deleting it would + // drop the signed steps). A node that cannot say leaves everything, and + // nothing is sent. + if let Some((name, send)) = lock_name { let network = derivation::network_from_profile(expected_network.as_deref().ok_or_else(|| { AppError::InvalidInput("the lock's wallet profile is gone; nothing was sent".into()) })?)?; if let Some(refusal) = - crate::commands::draft_ctx::recheck_lock_expiry(&client, network, &name).await? + crate::commands::draft_ctx::recheck_lock_expiry(&client, network, &name, send).await? { - if !maybe_sent { + if send == LockSend::Transfer && !maybe_sent { let conn = state.db.lock().map_err(|e| AppError::Lock(e.to_string()))?; db::queries::delete_tx_draft(&conn, &draft_id)?; } diff --git a/src-tauri/src/db/queries.rs b/src-tauri/src/db/queries.rs index d354f59..cf84307 100644 --- a/src-tauri/src/db/queries.rs +++ b/src-tauri/src/db/queries.rs @@ -2156,19 +2156,29 @@ pub fn list_shakedex_listings_finalizing( Ok(rows.collect::, _>>()?) } -/// `(profile, name, lock transfer txid)` of every ReadyToFinalize listing, -/// across profiles (the `listing_ready` reminder). +/// `(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 +/// ([`UNSENT_STATUSES`]) — signed, it locks nothing in until it is sent. pub fn list_listings_ready_to_finalize( conn: &rusqlite::Connection, ) -> Result, AppError> { - let mut stmt = conn.prepare( + let sql = format!( "SELECT wallet_profile_id, name, lock_transfer_txid FROM shakedex_listings - WHERE state = ?1 AND lock_transfer_txid IS NOT NULL + WHERE lock_transfer_txid IS NOT NULL + AND (state = ?1 + OR (state = ?2 AND EXISTS ( + SELECT 1 FROM wallet_tx_drafts d + WHERE d.id = shakedex_listings.lock_finalize_draft_id + AND d.status IN {}))) ORDER BY wallet_profile_id, name", + sql_list(UNSENT_STATUSES.iter().copied()) + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![ListingState::ReadyToFinalize, ListingState::Finalizing], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), )?; - let rows = stmt.query_map(params![ListingState::ReadyToFinalize], |r| { - Ok((r.get(0)?, r.get(1)?, r.get(2)?)) - })?; Ok(rows.collect::, _>>()?) } diff --git a/src-tauri/src/tests/deadlines_cmd_tests.rs b/src-tauri/src/tests/deadlines_cmd_tests.rs index a771cff..f3ec0f8 100644 --- a/src-tauri/src/tests/deadlines_cmd_tests.rs +++ b/src-tauri/src/tests/deadlines_cmd_tests.rs @@ -563,8 +563,9 @@ fn seed_listing(conn: &rusqlite::Connection, profile_id: &str, id: &str, name: & } /// R19: the reminder appears once the lockup is over (ReadyToFinalize, not -/// Locking), comes once a day until Finalize & sign runs, and is gone from -/// then on (Finalizing). +/// Locking), comes once a day until Finalize & sign runs and its FINALIZE +/// is sent (a signed FINALIZE not sent yet locks nothing in), and is gone +/// from then on. #[tokio::test] async fn listing_ready_repeats() { let state = create_full_test_state(); @@ -591,21 +592,67 @@ async fn listing_ready_repeats() { let d2 = scan(20_001).await.unwrap(); assert_eq!(d2.notified.len(), 1, "again the next day"); + let set_finalize = |status: &str| { + let s: tauri::State = app.state(); + let conn = s.db.lock().unwrap(); + conn.execute( + "UPDATE wallet_tx_drafts SET status = ?1 WHERE id = 'fin'", + [status], + ) + .unwrap(); + }; { let s: tauri::State = app.state(); let conn = s.db.lock().unwrap(); + db::queries::insert_tx_draft( + &conn, + "fin", + &profile_id, + "shakedex_lock_finalize", + "00", + "{}", + "{}", + ) + .unwrap(); conn.execute( - "UPDATE shakedex_listings SET state = 'finalizing' WHERE id = 'l1'", + "UPDATE shakedex_listings SET state = 'finalizing', lock_finalize_draft_id = 'fin' \ + WHERE id = 'l1'", [], ) .unwrap(); } + set_finalize("signed"); + let d3 = scan(20_002).await.unwrap(); + assert_eq!(d3.notified.len(), 1, "signed, not sent yet: still reminded"); + assert_eq!( + d3.notified[0].key, + format!("listing_ready:{profile_id}:forsale:l1-tx:20002") + ); + set_finalize("broadcasted"); assert!( - scan(20_002).await.unwrap().notified.is_empty(), - "gone once Finalize & sign ran" + scan(20_003).await.unwrap().notified.is_empty(), + "gone once the FINALIZE is sent" ); let s: tauri::State = app.state(); let conn = s.db.lock().unwrap(); let raw = db::queries::get_settings(&conn).unwrap()["deadline_notify_state"].clone(); assert!(!raw.contains("listing_ready:"), "{raw}"); } + +/// The listing reminder follows the setting like every other kind: off, a +/// ready listing notifies nothing and the scan says it is disabled. +#[tokio::test] +async fn listing_ready_respects_disabled_setting() { + let state = create_full_test_state(); + { + let conn = state.db.lock().unwrap(); + let id = insert_valid_profile(&conn, "mainnet"); + seed_listing(&conn, &id, "l1", "forsale", "ready_to_finalize"); + } + let app = mock_app_with(state); + let outcome = scan_deadline_notifications_on_day(app.handle().clone(), app.state(), 20_000) + .await + .unwrap(); + assert!(!outcome.enabled); + assert!(outcome.notified.is_empty()); +} diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index a5fd11f..7ff157f 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -726,26 +726,50 @@ fn finalizing_and_listed_listings_are_listed() { .is_empty()); } -/// The deadline scan's source: ReadyToFinalize listings only. +/// The deadline scan's source: ReadyToFinalize listings, and Finalizing ones +/// whose FINALIZE is signed but not sent yet (`draft`, `signed`): until it +/// is sent nothing is locked in. #[test] fn listings_ready_to_finalize_are_listed_for_the_reminder() { let conn = store_conn(); - for (id, name, state) in [ - ("l1", "ready", ListingState::ReadyToFinalize), - ("l2", "locking", ListingState::Locking), - ("l3", "fin", ListingState::Finalizing), + for (id, name, state, finalize) in [ + ("l1", "ready", ListingState::ReadyToFinalize, None), + ("l2", "locking", ListingState::Locking, None), + ("l3", "sent", ListingState::Finalizing, Some("broadcasted")), + ("l4", "unsent", ListingState::Finalizing, Some("signed")), + ("l5", "built", ListingState::Finalizing, Some("draft")), + ( + "l6", + "pending", + ListingState::Finalizing, + Some("broadcast_pending"), + ), + ("l7", "failed", ListingState::Finalizing, Some("failed")), + ("l8", "nodraft", ListingState::Finalizing, None), ] { let mut l = listing(id, name, state); l.lock_transfer_txid = Some(format!("{id}-tx")); + if let Some(status) = finalize { + insert_draft( + &conn, + &format!("{id}-fin"), + sell::LOCK_FINALIZE_ACTION, + status, + ); + l.lock_finalize_draft_id = Some(format!("{id}-fin")); + } queries::insert_shakedex_listing(&conn, &l).unwrap(); } + let row = |name: &str, id: &str| { + ( + STORE_PROFILE.to_string(), + name.to_string(), + format!("{id}-tx"), + ) + }; assert_eq!( queries::list_listings_ready_to_finalize(&conn).unwrap(), - vec![( - STORE_PROFILE.to_string(), - "ready".to_string(), - "l1-tx".to_string() - )] + vec![row("built", "l5"), row("ready", "l1"), row("unsent", "l4")] ); } @@ -2907,6 +2931,44 @@ async fn no_broadcast(r: &mut Ready) -> Mock { .await } +/// R31 again when the FINALIZE into the lock is sent, with 0 lockup left: +/// it may be sent days after Finalize & sign. Refused with Finalize & +/// sign's own sentence while the expiry end is at or below tip + 1 + day, +/// nothing reaches the node, and the signed draft and the Finalizing listing +/// stay; one block earlier it is sent. +#[tokio::test] +async fn finalize_broadcast_refused_when_the_name_would_expire_first() { + let day = i64::from(Network::Regtest.name_params().margin_day()); + for (tip, sends) in [(REGTEST_END - 1 - day, 0), (REGTEST_END - 2 - day, 1)] { + let r = ready_fixture("regtest").await; + finalized(&r).await; + let fin_draft = r.listing().lock_finalize_draft_id.unwrap(); + let (node, m) = broadcast_node(tip, sends).await; + with_db(&r.app, |c| set(c, "node_rpc_url", &node.url())); + let sent = crate::commands::tx::broadcast_tx_draft(r.app.state(), fin_draft.clone()).await; + m[2].assert_async().await; + if sends == 0 { + let err = err_text(sent.unwrap_err()); + assert!( + err.contains("too soon to finalize it into the lock"), + "R31 refusal: {err}" + ); + let l = r.listing(); + assert_eq!(l.state, ListingState::Finalizing); + assert_eq!( + l.lock_finalize_draft_id.as_deref(), + Some(fin_draft.as_str()) + ); + let status = with_db(&r.app, |c| queries::get_tx_draft(c, &fin_draft).unwrap()) + .expect("the signed draft is kept") + .status; + assert_eq!(status, "signed"); + } else { + sent.expect("one block earlier the FINALIZE is sent"); + } + } +} + /// R19 day 2 end to end: one confirmation, then the FINALIZE into the lock /// is a signed draft spending our lock TRANSFER into a FINALIZE at the lock /// address, the listing is Finalizing with the lock outpoint (the From 2c1d5ced0c6dbb32e56efd5e7452a214c804818c Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 17:14:40 +0200 Subject: [PATCH 15/17] test(shakedex): pin the stored lock key check and the signed FINALIZE's txid Finalize & sign refuses a listing whose stored lock public key is not the re-derived one even when the TRANSFER commits to the derived lock (dropping that half of the check fails the test). The end-to-end Finalize & sign test now decodes the signed FINALIZE and checks that its txid is the listing's lock txid and the listing file's lockingTxHash. --- src-tauri/src/tests/shakedex_sell_tests.rs | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index 7ff157f..2450167 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -2369,8 +2369,9 @@ async fn prepare_lock_finalize_builds_the_finalize_into_the_lock() { /// other program (another key's lock, another address) is refused before /// anything is built, asked or written, also when the database's copy of /// the public key says the other key (the key is re-derived, never read -/// back); so is one the node reports without a readable covenant (could not -/// check). +/// back); a stored public key that is not the re-derived one is refused even +/// when the coin commits to the derived lock; so is a coin the node reports +/// without a readable covenant (could not check). #[tokio::test] async fn refuses_finalize_on_commitment_mismatch() { let mut r = ready_fixture("regtest").await; @@ -2400,6 +2401,17 @@ async fn refuses_finalize_on_commitment_mismatch() { }); let err = prepare(&r, &price("5")).await.err().expect("refused"); assert!(err_text(err).contains(sell::LOCK_COMMITMENT_MISMATCH)); + + // The coin commits to the re-derived key's lock, but the stored public + // key is another key's: the listing is not the lock this wallet derives + // (its lock address, which the jobs read, would be the other one). + r.node(ready_tip(net), Some(SIGN_MTP), info.clone(), Some(r.coin())) + .await; + let err = prepare(&r, &price("5")) + .await + .err() + .expect("stored key differs"); + assert!(err_text(err).contains(sell::LOCK_COMMITMENT_MISMATCH)); with_db(&r.app, |c| { c.execute( "UPDATE shakedex_listings SET lock_pubkey_hex = ?1 WHERE id = ?2", @@ -2407,6 +2419,9 @@ async fn refuses_finalize_on_commitment_mismatch() { ) .unwrap(); }); + prepare(&r, &price("5")) + .await + .expect("the stored key and the coin agree with the derived key"); let good = r.coin(); let mut no_cov = good.clone(); @@ -3063,6 +3078,20 @@ async fn lock_then_finalize_and_sign() { (file.lock_txid, file.lock_vout), (outpoint, lock_vout as u32) ); + // The transaction that will be sent is the one the steps are over: the + // signed FINALIZE's own txid, decoded from its bytes, is the listing's + // lock txid and the file's `lockingTxHash`. + let signed = crate::noncustodial::tx::Transaction::decode( + &hex::decode(row.signed_tx_hex.as_deref().unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(Some(signed.txid()), l.lock_txid); + assert_eq!(signed.txid(), hex::encode(file.lock_txid)); + let raw_file: Value = serde_json::from_str(l.listing_file_json.as_deref().unwrap()).unwrap(); + assert_eq!( + raw_file["lockingTxHash"].as_str(), + Some(signed.txid().as_str()) + ); assert_eq!(file.public_key, key.pubkey); assert_eq!(file.payment_addr, l.payment_address.clone().unwrap()); assert_eq!(file.steps.len(), 1); From 87e6ab867fc7d7b8836f840c589589935d37fe0f Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 17:20:22 +0200 Subject: [PATCH 16/17] test(shakedex): a reorg of the FINALIZE into the lock and a FINALIZE from another device on a live node shakedex_listing_follows_reorg_of_its_lock_finalize invalidates the block holding a Listed listing's FINALIZE: while hsd has it nowhere the listing stays Listed, handed back to the node it is a mempool coin and the listing Finalizing, mined again Listed, each step read from hsd first. shakedex_finalize_from_another_device_is_a_restored_lock Finalize & signs from a copy of the wallet's database (another device with the same recovery phrase) and mines the FINALIZE; the first wallet's ReadyToFinalize listing becomes a Restored lock with the outpoint hsd reports as the name's owner. The CLI test no longer compares the file it wrote for the CLI with itself. --- docs/REGTEST_TESTING.md | 4 +- src-tauri/src/tests/live_node_it.rs | 143 ++++++++++++++++++++++++++-- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/docs/REGTEST_TESTING.md b/docs/REGTEST_TESTING.md index 806b59a..2485915 100644 --- a/docs/REGTEST_TESTING.md +++ b/docs/REGTEST_TESTING.md @@ -380,7 +380,7 @@ HNS_IT_NODE_URL=http://127.0.0.1:14037 HNS_IT_NODE_API_KEY=test HNS_IT_SHAKEDEX= cargo test --manifest-path src-tauri/Cargo.toml --lib live_node_it::shakedex_ -- --test-threads=1 --nocapture ``` -The tests drive the CLI through `scripts/shakedex-cli-sell.sh`, with the node's hsd wallet as the seller and as the other buyer. Each run registers fresh names (open, two bids, reveal, register, mining between the phases), so a used chain works, up to a point: regtest halves the block subsidy every 2500 blocks (`halvingInterval`), and the tests fund their wallets by mining, so a chain some tens of thousands of blocks tall leaves them too little to spend. A full run of the live suite mines about 10 000 blocks (the Shakedex tests about 3000 of them, registering names and funding buyers), so start over after two runs; the Shakedex buyer refuses to start on a chain past 30 000 blocks and says why; `scripts/regtest.sh reset` starts over. Each test funds an account of its own (`seeded_conn_regtest` hands out a fresh one): the tests once shared account 0, which gathered thousands of coinbases a run, until a sweep of it outgrew a standard transaction and stalled every later test behind an unmined transaction. The R9 test below mines about 5000 blocks and takes them back out with `invalidateblock` before it asserts anything, so it leaves the chain as tall as it found it. The 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, and `shakedex_cancel_transfer_aborts_the_listing_on_chain`) are gated by `HNS_IT_SHAKEDEX` like the rest, but use no CLI: they register a name with the wallet itself, so they also run against a node at another port. The script clones `shadstoneofficial/shakedex` at the pinned commit `2c4fa04eab68a528e758598d11b5da5666113b11` into `SHAKEDEX_WORK` once and keeps the CLI's database there (the tests default it to `$TMPDIR/namehold-shakedex-cli`; run by hand, the script uses a fresh temp dir unless it is set); nothing is installed globally. `HNS_IT_SHAKEDEX` set to anything but `1` or empty, or set to `1` without `HNS_IT_NODE_URL`, or with a node URL other than the regtest RPC port (14037), fails the tests instead of skipping them. It can be run by hand too: +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: | Command | What the CLI does | |---|---| @@ -395,6 +395,8 @@ 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_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_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/src-tauri/src/tests/live_node_it.rs b/src-tauri/src/tests/live_node_it.rs index 1bea8f5..2f607b0 100644 --- a/src-tauri/src/tests/live_node_it.rs +++ b/src-tauri/src/tests/live_node_it.rs @@ -6115,13 +6115,7 @@ async fn shakedex_namehold_buy_now_is_bought_by_the_cli() { cl.generate_to_address(maturity, &addr) .await .expect("mature its coinbases"); - cli.fill(&CliListing::new(file.clone())); - let bought = - std::fs::read_to_string(cli.work.join("fill.json")).expect("the file the CLI read"); - assert_eq!( - bought, file, - "the CLI bought the exported file, byte for byte" - ); + 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"); @@ -6166,6 +6160,141 @@ 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. +#[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") + else { + return; + }; + let (app, cl, addr, _name, id) = ready_to_finalize_on_chain(&url, &key, "nhreorg").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 lock_coin = |cl: NodeRpcClient| { + let txid = lock_txid.clone(); + async move { cl.get_coin(&txid, lock_vout).await.expect("coin lookup") } + }; + let mined_at = lock_coin(cl.clone()) + .await + .expect("the lock coin") + .mined_height() + .unwrap() + .expect("mined"); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Listed); + + let block = cl.get_block_hash(mined_at).await.expect("blockhash"); + cl.invalidate_block(&block).await.expect("invalidate"); + assert!( + lock_coin(cl.clone()).await.is_none(), + "invalidateblock leaves the FINALIZE nowhere" + ); + listing_jobs(&app, &cl).await; + assert_eq!( + listing_state(&app, &id), + ListingState::Listed, + "a 404 alone is no verdict" + ); + + let fin = draft_status(&app, s.finalize_draft_id.as_deref().expect("draft")); + cl.send_raw_transaction(fin.signed_tx_hex.as_deref().expect("signed FINALIZE")) + .await + .expect("hand the FINALIZE back"); + wait_until_node_has(&cl, &lock_txid).await; + let pending = lock_coin(cl.clone()).await.expect("the lock coin, unmined"); + assert_eq!(pending.mined_height().unwrap(), None, "in the mempool"); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Finalizing); + + cl.generate_to_address(1, &addr).await.expect("mine"); + cl.reconsider_block(&block).await.expect("reconsider"); + let again = lock_coin(cl.clone()).await.expect("the lock coin"); + assert!( + again.mined_height().unwrap().is_some(), + "mined again: {again:?}" + ); + listing_jobs(&app, &cl).await; + assert_eq!(listing_state(&app, &id), ListingState::Listed); +} + +/// R19 coordinator (b) on hsd: another device with the same recovery phrase +/// (here: a copy of this wallet's database, as a restore would give it) +/// Finalize & signs the listing and its FINALIZE is mined. This device's +/// listing, still ReadyToFinalize, becomes a Restored lock with that +/// FINALIZE's lock outpoint, read from hsd's owner coin — never Aborted. +#[tokio::test] +async fn shakedex_finalize_from_another_device_is_a_restored_lock() { + let Some((url, key)) = + shakedex_node_env("shakedex_finalize_from_another_device_is_a_restored_lock") + else { + return; + }; + let (app, cl, addr, name, id) = ready_to_finalize_on_chain(&url, &key, "nhother").await; + let copy = std::env::temp_dir().join(format!( + "namehold_live_other_device_{}_{}.db", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + { + let state = app.state::(); + let conn = state.db.lock().unwrap(); + conn.execute("VACUUM INTO ?1", [copy.to_str().unwrap()]) + .expect("copy the database"); + } + let other_conn = rusqlite::Connection::open(©).unwrap(); + other_conn + .execute_batch("PRAGMA foreign_keys = ON;") + .unwrap(); + let other = app_with(other_conn); + let s = finalize_and_sign_on_chain(&other, &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(); + + // On hsd: the name's owner is that FINALIZE's lock coin, mined at the + // lock address of the key derived here. + let lock = crate::noncustodial::shakedex::lock_key::derive_lock_key( + &master(), + NET, + test_acct(), + &name, + ) + .unwrap(); + assert_eq!(name_owner(&cl, &name).await, (lock_txid.clone(), lock_vout)); + let coin = cl + .get_coin(&lock_txid, lock_vout) + .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 + ); + assert!(coin.mined_height().unwrap().is_some(), "mined: {coin:?}"); + + assert_eq!(listing_state(&app, &id), ListingState::ReadyToFinalize); + sync_wallet_state(app.state(), None).await.expect("sync"); + listing_jobs(&app, &cl).await; + let l = open_listing(&app, &name).expect("still the open listing"); + assert_eq!(l.id, id); + assert_eq!(l.state, ListingState::Restored); + assert_eq!( + (l.lock_txid.as_deref(), l.lock_vout), + (Some(lock_txid.as_str()), Some(i64::from(lock_vout))) + ); + drop(other); + let _ = std::fs::remove_file(©); +} + /// 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. From 1edcd792c6a291c83562be1ef0089ea9d4bb8f3f Mon Sep 17 00:00:00 2001 From: Dmitriy Zhavoronkov Date: Fri, 9 Oct 2026 17:22:24 +0200 Subject: [PATCH 17/17] docs(shakedex): say when the self-check signs and the median time is read, and type the listing state for the UI SECURITY.md section 5 and spec R20 now say that Finalize & sign makes one 0x84 signature before its prompt, the R18 self-check's over a template with a zero outpoint, verified and dropped, and that the median time for the lock time is read just before the prompt, not after; R19 and the buy_now_lock_time doc say the same. SECURITY.md also lists the export refusals for a listing that is over. ShakedexListingSummary.state is the union of the 13 camelCase ListingState spellings, pinned against Rust by shakedex_sell_tests::ts_listing_state_union_lists_every_state; spec R19 names the two new live tests. The 035 migration comment names the lock output's index instead of output 0. --- SECURITY.md | 2 +- docs/specs/2026-10-05-shakedex-name-sales.md | 6 ++--- src-tauri/src/noncustodial/shakedex/sell.rs | 4 +-- .../src/sql/035_shakedex_listing_finalize.sql | 3 ++- src-tauri/src/tests/shakedex_sell_tests.rs | 25 +++++++++++++++++++ src/types/index.ts | 20 +++++++++++++-- 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index fb67ca5..532136a 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 (`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 at signing minus 512 seconds, 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. +**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. --- diff --git a/docs/specs/2026-10-05-shakedex-name-sales.md b/docs/specs/2026-10-05-shakedex-name-sales.md index 5b0d692..fe59c9d 100644 --- a/docs/specs/2026-10-05-shakedex-name-sales.md +++ b/docs/specs/2026-10-05-shakedex-name-sales.md @@ -113,12 +113,12 @@ A mined purchase whose TRANSFER is spent on chain is never lost: only our FINALI *Enforced:* `noncustodial/shakedex/sell.rs::lock_self_check`, `commands/shakedex.rs::{shakedex_build_lock_draft, shakedex_finalize_and_sign}`. *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 at signing minus 512 s, so it is valid at once by R3; it is taken when the steps are signed, not when the name was locked. +**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`. +*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}`. **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`); nothing is signed or written unless it is confirmed. +*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. *Pinned:* `shakedex_sell_tests::{confirmation_lists_every_step, cancelled_confirmation_writes_nothing}`, `noncustodial::shakedex::sell` unit test `finalize_and_sign_rows_list_fee_steps_and_permanence`. **R21 — Proceeds go to a fresh address, reserved.** Each listing pays a receive address that is marked used when the listing is created, so the next listing gets another one; after a restore it is found only within the restore window of `address_gap_limit` addresses (window extension is a known gap, §5). The cancel destination is reserved the same way (a buyer's name destination already is, R13). diff --git a/src-tauri/src/noncustodial/shakedex/sell.rs b/src-tauri/src/noncustodial/shakedex/sell.rs index db5ff2e..e402b7d 100644 --- a/src-tauri/src/noncustodial/shakedex/sell.rs +++ b/src-tauri/src/noncustodial/shakedex/sell.rs @@ -61,8 +61,8 @@ pub fn lock_self_check(key: &LockKey, network: Network) -> Result<(), AppError> Ok(()) } -/// R19: a Buy Now's lock time, taken from the MTP of the tip when the step is -/// signed: one lock-time unit back, so its encoded value is below that MTP +/// R19: a Buy Now's lock time, from the MTP the node reports at Finalize & +/// sign (read just before its confirmation): one lock-time unit back, so its encoded value is below that MTP /// and the step is valid in the next block (`template::is_valid_at`). pub fn buy_now_lock_time(mtp: u64) -> u64 { mtp.saturating_sub(LOCK_TIME_UNIT_SECS) diff --git a/src-tauri/src/sql/035_shakedex_listing_finalize.sql b/src-tauri/src/sql/035_shakedex_listing_finalize.sql index 429126a..76a7fa9 100644 --- a/src-tauri/src/sql/035_shakedex_listing_finalize.sql +++ b/src-tauri/src/sql/035_shakedex_listing_finalize.sql @@ -1,4 +1,5 @@ -- T3: the FINALIZE into the lock built by Finalize & sign. Its txid is the --- listing's lock_txid (output 0); the draft is kept to tell an unsent or +-- listing's lock_txid, and lock_vout the index of its FINALIZE output into +-- the lock (sell::lock_output); the draft is kept to tell an unsent or -- refused FINALIZE from one that may still land. ALTER TABLE shakedex_listings ADD COLUMN lock_finalize_draft_id TEXT; diff --git a/src-tauri/src/tests/shakedex_sell_tests.rs b/src-tauri/src/tests/shakedex_sell_tests.rs index 2450167..f458470 100644 --- a/src-tauri/src/tests/shakedex_sell_tests.rs +++ b/src-tauri/src/tests/shakedex_sell_tests.rs @@ -84,6 +84,31 @@ fn every_listing_state_round_trips_through_the_table() { ); } +/// The TS mirror `ShakedexListingState` (`src/types/index.ts`) lists +/// exactly the spellings serde sends for `ListingState`. +#[test] +fn ts_listing_state_union_lists_every_state() { + let ts = include_str!("../../../src/types/index.ts"); + let start = ts + .find("export type ShakedexListingState =") + .expect("the TS union"); + let block = &ts[start..start + ts[start..].find(';').expect("end of the union")]; + let mut in_ts: Vec<&str> = block.split('"').skip(1).step_by(2).collect(); + in_ts.sort_unstable(); + let mut in_rust: Vec = ListingState::ALL + .iter() + .map(|s| { + serde_json::to_value(s) + .unwrap() + .as_str() + .unwrap() + .to_string() + }) + .collect(); + in_rust.sort_unstable(); + assert_eq!(in_ts, in_rust); +} + #[test] fn an_unknown_listing_state_is_refused_when_read() { assert!("owned".parse::().is_err()); diff --git a/src/types/index.ts b/src/types/index.ts index ef0814d..02b4a47 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -887,12 +887,28 @@ export interface ShakedexStoredStep { } /** One of our listings (`commands/shakedex.rs::ListingSummary`). */ +/** Every spelling of Rust's `ListingState` (`db::queries::ListingState::ALL`), camelCase as serde sends it. */ +export type ShakedexListingState = + | "locking" + | "readyToFinalize" + | "finalizing" + | "listed" + | "salePending" + | "sold" + | "cancelling" + | "cancelAwaitingFinalize" + | "cancelFinalizing" + | "cancelled" + | "aborted" + | "restored" + | "expired"; + export interface ShakedexListingSummary { id: string; name: string; mode: "buyNow" | "reverseAuction"; - /** `ListingState` in camelCase ("locking", "readyToFinalize", "finalizing", "listed", ...). */ - state: string; + /** Rust's `ListingState`, in camelCase. */ + state: ShakedexListingState; lockTxid: string | null; lockVout: number | null; paymentAddress: string | null;