diff --git a/crates/mockcore/src/server.rs b/crates/mockcore/src/server.rs index d034266106..bd62aab5b4 100644 --- a/crates/mockcore/src/server.rs +++ b/crates/mockcore/src/server.rs @@ -296,7 +296,7 @@ impl Api for Server { keypool_size: 0, keypool_size_hd_internal: 0, pay_tx_fee: Amount::from_sat(0), - private_keys_enabled: false, + private_keys_enabled: true, scanning: None, tx_count: 0, unconfirmed_balance: Amount::from_sat(0), diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index 0c713df8cd..28b51b0806 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -12,6 +12,7 @@ pub mod create; pub mod dump; pub mod inscribe; pub mod inscriptions; +mod label; pub mod mint; pub mod outputs; pub mod receive; @@ -44,6 +45,8 @@ pub(crate) enum Subcommand { Balance, #[command(about = "Create inscriptions and runes")] Batch(batch_command::Batch), + #[command(about = "List unspent cardinal outputs in wallet")] + Cardinals, #[command(about = "Create new wallet")] Create(create::Create), #[command(about = "Dump wallet descriptors")] @@ -52,8 +55,12 @@ pub(crate) enum Subcommand { Inscribe(inscribe::Inscribe), #[command(about = "List wallet inscriptions")] Inscriptions, + #[command(about = "Export output labels")] + Label, #[command(about = "Mint a rune")] Mint(mint::Mint), + #[command(about = "List all unspent outputs in wallet")] + Outputs, #[command(about = "Generate receive address")] Receive(receive::Receive), #[command(about = "Restore wallet")] @@ -66,10 +73,6 @@ pub(crate) enum Subcommand { Send(send::Send), #[command(about = "See wallet transactions")] Transactions(transactions::Transactions), - #[command(about = "List all unspent outputs in wallet")] - Outputs, - #[command(about = "List unspent cardinal outputs in wallet")] - Cardinals, } impl WalletCommand { @@ -97,18 +100,19 @@ impl WalletCommand { match self.subcommand { Subcommand::Balance => balance::run(wallet), Subcommand::Batch(batch) => batch.run(wallet), + Subcommand::Cardinals => cardinals::run(wallet), + Subcommand::Create(_) | Subcommand::Restore(_) => unreachable!(), Subcommand::Dump => dump::run(wallet), Subcommand::Inscribe(inscribe) => inscribe.run(wallet), Subcommand::Inscriptions => inscriptions::run(wallet), + Subcommand::Label => label::run(wallet), Subcommand::Mint(mint) => mint.run(wallet), + Subcommand::Outputs => outputs::run(wallet), Subcommand::Receive(receive) => receive.run(wallet), Subcommand::Resume => resume::run(wallet), Subcommand::Sats(sats) => sats.run(wallet), Subcommand::Send(send) => send.run(wallet), Subcommand::Transactions(transactions) => transactions.run(wallet), - Subcommand::Outputs => outputs::run(wallet), - Subcommand::Cardinals => cardinals::run(wallet), - Subcommand::Create(_) | Subcommand::Restore(_) => unreachable!(), } } } diff --git a/src/subcommand/wallet/label.rs b/src/subcommand/wallet/label.rs new file mode 100644 index 0000000000..646db83ec2 --- /dev/null +++ b/src/subcommand/wallet/label.rs @@ -0,0 +1,71 @@ +use super::*; + +#[derive(Serialize)] +struct Label { + first_sat: SatLabel, + inscriptions: BTreeMap>, +} + +#[derive(Serialize)] +struct SatLabel { + name: String, + number: u64, + rarity: Rarity, +} + +#[derive(Serialize)] +struct Line { + label: String, + r#ref: String, + r#type: String, +} + +pub(crate) fn run(wallet: Wallet) -> SubcommandResult { + let mut lines: Vec = Vec::new(); + + let sat_ranges = wallet.get_output_sat_ranges()?; + + let mut inscriptions_by_output: BTreeMap>> = + BTreeMap::new(); + + for (satpoint, inscriptions) in wallet.inscriptions() { + inscriptions_by_output + .entry(satpoint.outpoint) + .or_default() + .insert(satpoint.offset, inscriptions.clone()); + } + + for (output, ranges) in sat_ranges { + let sat = Sat(ranges[0].0); + let mut inscriptions = BTreeMap::>::new(); + + if let Some(output_inscriptions) = inscriptions_by_output.get(&output) { + for (&offset, offset_inscriptions) in output_inscriptions { + inscriptions + .entry(offset) + .or_default() + .extend(offset_inscriptions); + } + } + + lines.push(Line { + label: serde_json::to_string(&Label { + first_sat: SatLabel { + name: sat.name(), + number: sat.n(), + rarity: sat.rarity(), + }, + inscriptions, + })?, + r#ref: output.to_string(), + r#type: "output".into(), + }); + } + + for line in lines { + serde_json::to_writer(io::stdout(), &line)?; + println!(); + } + + Ok(None) +} diff --git a/src/subcommand/wallet/sats.rs b/src/subcommand/wallet/sats.rs index d7ef27e4a9..7cdbe72c74 100644 --- a/src/subcommand/wallet/sats.rs +++ b/src/subcommand/wallet/sats.rs @@ -11,8 +11,8 @@ pub(crate) struct Sats { #[derive(Serialize, Deserialize)] pub struct OutputTsv { - pub sat: String, - pub output: OutPoint, + pub found: BTreeMap, + pub lost: BTreeSet, } #[derive(Serialize, Deserialize)] @@ -30,24 +30,26 @@ impl Sats { "sats requires index created with `--index-sats` flag" ); - let utxos = wallet.get_output_sat_ranges()?; + let haystacks = wallet.get_output_sat_ranges()?; if let Some(path) = &self.tsv { - let mut output = Vec::new(); - for (outpoint, sat) in sats_from_tsv( - utxos, - &fs::read_to_string(path) - .with_context(|| format!("I/O error reading `{}`", path.display()))?, - )? { - output.push(OutputTsv { - sat: sat.into(), - output: outpoint, - }); - } - Ok(Some(Box::new(output))) + let tsv = fs::read_to_string(path) + .with_context(|| format!("I/O error reading `{}`", path.display()))?; + + let needles = Self::needles(&tsv)?; + + let found = Self::find(&needles, &haystacks); + + let lost = needles + .into_iter() + .filter(|(_sat, value)| !found.contains_key(*value)) + .map(|(_sat, value)| value.into()) + .collect(); + + Ok(Some(Box::new(OutputTsv { found, lost }))) } else { let mut output = Vec::new(); - for (outpoint, sat, offset, rarity) in rare_sats(utxos) { + for (outpoint, sat, offset, rarity) in Self::rare_sats(haystacks) { output.push(OutputRare { sat, output: outpoint, @@ -58,90 +60,101 @@ impl Sats { Ok(Some(Box::new(output))) } } -} -fn rare_sats(utxos: Vec<(OutPoint, Vec<(u64, u64)>)>) -> Vec<(OutPoint, Sat, u64, Rarity)> { - utxos - .into_iter() - .flat_map(|(outpoint, sat_ranges)| { + fn find( + needles: &[(Sat, &str)], + ranges: &[(OutPoint, Vec<(u64, u64)>)], + ) -> BTreeMap { + let mut haystacks = Vec::new(); + + for (outpoint, ranges) in ranges { let mut offset = 0; - sat_ranges.into_iter().filter_map(move |(start, end)| { - let sat = Sat(start); - let rarity = sat.rarity(); - let start_offset = offset; + for (start, end) in ranges { + haystacks.push((start, end, offset, outpoint)); offset += end - start; - if rarity > Rarity::Common { - Some((outpoint, sat, start_offset, rarity)) - } else { - None - } - }) - }) - .collect() -} - -fn sats_from_tsv( - utxos: Vec<(OutPoint, Vec<(u64, u64)>)>, - tsv: &str, -) -> Result> { - let mut needles = Vec::new(); - for (i, line) in tsv.lines().enumerate() { - if line.is_empty() || line.starts_with('#') { - continue; + } } - if let Some(value) = line.split('\t').next() { - let sat = Sat::from_str(value).map_err(|err| { - anyhow!( - "failed to parse sat from string \"{value}\" on line {}: {err}", - i + 1, - ) - })?; + haystacks.sort_by_key(|(start, _, _, _)| *start); + + let mut i = 0; + let mut j = 0; + let mut results = BTreeMap::new(); + while i < needles.len() && j < haystacks.len() { + let (needle, value) = needles[i]; + let (&start, &end, offset, outpoint) = haystacks[j]; + + if needle >= start && needle < end { + results.insert( + value.into(), + SatPoint { + outpoint: *outpoint, + offset: offset + needle.0 - start, + }, + ); + } - needles.push((sat, value)); + if needle >= end { + j += 1; + } else { + i += 1; + } } + + results } - needles.sort(); - let mut haystacks = utxos - .into_iter() - .flat_map(|(outpoint, ranges)| { - ranges - .into_iter() - .map(move |(start, end)| (start, end, outpoint)) - }) - .collect::>(); - haystacks.sort(); - - let mut i = 0; - let mut j = 0; - let mut results = Vec::new(); - while i < needles.len() && j < haystacks.len() { - let (needle, value) = needles[i]; - let (start, end, outpoint) = haystacks[j]; - - if needle >= start && needle < end { - results.push((outpoint, value)); - } + fn needles(tsv: &str) -> Result> { + let mut needles = tsv + .lines() + .enumerate() + .filter(|(_i, line)| !line.starts_with('#') && !line.is_empty()) + .filter_map(|(i, line)| { + line.split('\t').next().map(|value| { + Sat::from_str(value).map(|sat| (sat, value)).map_err(|err| { + anyhow!( + "failed to parse sat from string \"{value}\" on line {}: {err}", + i + 1, + ) + }) + }) + }) + .collect::>>()?; - if needle >= end { - j += 1; - } else { - i += 1; - } + needles.sort(); + + Ok(needles) } - Ok(results) + fn rare_sats(haystacks: Vec<(OutPoint, Vec<(u64, u64)>)>) -> Vec<(OutPoint, Sat, u64, Rarity)> { + haystacks + .into_iter() + .flat_map(|(outpoint, sat_ranges)| { + let mut offset = 0; + sat_ranges.into_iter().filter_map(move |(start, end)| { + let sat = Sat(start); + let rarity = sat.rarity(); + let start_offset = offset; + offset += end - start; + if rarity > Rarity::Common { + Some((outpoint, sat, start_offset, rarity)) + } else { + None + } + }) + }) + .collect() + } } #[cfg(test)] mod tests { - use {super::*, std::fmt::Write}; + use super::*; #[test] fn identify_no_rare_sats() { assert_eq!( - rare_sats(vec![( + Sats::rare_sats(vec![( outpoint(1), vec![(51 * COIN_VALUE, 100 * COIN_VALUE), (1234, 5678)], )]), @@ -152,7 +165,7 @@ mod tests { #[test] fn identify_one_rare_sat() { assert_eq!( - rare_sats(vec![( + Sats::rare_sats(vec![( outpoint(1), vec![(10, 80), (50 * COIN_VALUE, 100 * COIN_VALUE)], )]), @@ -163,7 +176,7 @@ mod tests { #[test] fn identify_two_rare_sats() { assert_eq!( - rare_sats(vec![( + Sats::rare_sats(vec![( outpoint(1), vec![(0, 100), (1050000000000000, 1150000000000000)], )]), @@ -177,7 +190,7 @@ mod tests { #[test] fn identify_rare_sats_in_different_outpoints() { assert_eq!( - rare_sats(vec![ + Sats::rare_sats(vec![ (outpoint(1), vec![(50 * COIN_VALUE, 55 * COIN_VALUE)]), (outpoint(2), vec![(100 * COIN_VALUE, 111 * COIN_VALUE)],), ]), @@ -188,134 +201,110 @@ mod tests { ) } - #[test] - fn identify_from_tsv_none() { + #[track_caller] + fn case(tsv: &str, haystacks: &[(OutPoint, Vec<(u64, u64)>)], expected: &[(&str, SatPoint)]) { assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 1)])], "1\n").unwrap(), - Vec::new() - ) + Sats::find(&Sats::needles(tsv).unwrap(), haystacks), + expected + .iter() + .map(|(sat, satpoint)| (sat.to_string(), *satpoint)) + .collect() + ); + } + + #[test] + fn tsv() { + case("1\n", &[(outpoint(1), vec![(0, 1)])], &[]); } #[test] fn identify_from_tsv_single() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 1)])], "0\n").unwrap(), - vec![(outpoint(1), "0"),] - ) + case( + "0\n", + &[(outpoint(1), vec![(0, 1)])], + &[("0", satpoint(1, 0))], + ); } #[test] fn identify_from_tsv_two_in_one_range() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 2)])], "0\n1\n").unwrap(), - vec![(outpoint(1), "0"), (outpoint(1), "1"),] - ) + case( + "0\n1\n", + &[(outpoint(1), vec![(0, 2)])], + &[("0", satpoint(1, 0)), ("1", satpoint(1, 1))], + ); } #[test] fn identify_from_tsv_out_of_order_tsv() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 2)])], "1\n0\n").unwrap(), - vec![(outpoint(1), "0"), (outpoint(1), "1"),] - ) + case( + "1\n0\n", + &[(outpoint(1), vec![(0, 2)])], + &[("0", satpoint(1, 0)), ("1", satpoint(1, 1))], + ); } #[test] fn identify_from_tsv_out_of_order_ranges() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(1, 2), (0, 1)])], "1\n0\n").unwrap(), - vec![(outpoint(1), "0"), (outpoint(1), "1"),] - ) + case( + "1\n0\n", + &[(outpoint(1), vec![(1, 2), (0, 1)])], + &[("0", satpoint(1, 1)), ("1", satpoint(1, 0))], + ); } #[test] fn identify_from_tsv_two_in_two_ranges() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 1), (1, 2)])], "0\n1\n").unwrap(), - vec![(outpoint(1), "0"), (outpoint(1), "1"),] + case( + "0\n1\n", + &[(outpoint(1), vec![(0, 1), (1, 2)])], + &[("0", satpoint(1, 0)), ("1", satpoint(1, 1))], ) } #[test] fn identify_from_tsv_two_in_two_outputs() { - assert_eq!( - sats_from_tsv( - vec![(outpoint(1), vec![(0, 1)]), (outpoint(2), vec![(1, 2)])], - "0\n1\n" - ) - .unwrap(), - vec![(outpoint(1), "0"), (outpoint(2), "1"),] - ) + case( + "0\n1\n", + &[(outpoint(1), vec![(0, 1)]), (outpoint(2), vec![(1, 2)])], + &[("0", satpoint(1, 0)), ("1", satpoint(2, 0))], + ); } #[test] fn identify_from_tsv_ignores_extra_columns() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 1)])], "0\t===\n").unwrap(), - vec![(outpoint(1), "0"),] - ) + case( + "0\t===\n", + &[(outpoint(1), vec![(0, 1)])], + &[("0", satpoint(1, 0))], + ); } #[test] fn identify_from_tsv_ignores_empty_lines() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 1)])], "0\n\n\n").unwrap(), - vec![(outpoint(1), "0"),] - ) + case( + "0\n\n\n", + &[(outpoint(1), vec![(0, 1)])], + &[("0", satpoint(1, 0))], + ); } #[test] fn identify_from_tsv_ignores_comments() { - assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 1)])], "0\n#===\n").unwrap(), - vec![(outpoint(1), "0"),] - ) + case( + "0\n#===\n", + &[(outpoint(1), vec![(0, 1)])], + &[("0", satpoint(1, 0))], + ); } #[test] fn parse_error_reports_line_and_value() { assert_eq!( - sats_from_tsv(vec![(outpoint(1), vec![(0, 1)])], "0\n===\n") + Sats::needles("0\n===\n") .unwrap_err() .to_string(), "failed to parse sat from string \"===\" on line 2: failed to parse sat `===`: invalid integer: invalid digit found in string", - ) - } - - #[test] - fn identify_from_tsv_is_fast() { - let mut start = 0; - let mut utxos = Vec::new(); - let mut results = Vec::new(); - for i in 0..16 { - let mut ranges = Vec::new(); - let outpoint = outpoint(i); - for _ in 0..100 { - let end = start + 50 * COIN_VALUE; - ranges.push((start, end)); - for j in 0..50 { - results.push((outpoint, start + j * COIN_VALUE)); - } - start = end; - } - utxos.push((outpoint, ranges)); - } - - let mut tsv = String::new(); - for i in 0..start / COIN_VALUE { - writeln!(tsv, "{}", i * COIN_VALUE).expect("writing to string should succeed"); - } - - let start = Instant::now(); - assert_eq!( - sats_from_tsv(utxos, &tsv) - .unwrap() - .into_iter() - .map(|(outpoint, s)| (outpoint, s.parse().unwrap())) - .collect::>(), - results ); - - assert!(Instant::now() - start < Duration::from_secs(10)); } } diff --git a/src/wallet/wallet_constructor.rs b/src/wallet/wallet_constructor.rs index f485f6f8fb..1db7ee5c6b 100644 --- a/src/wallet/wallet_constructor.rs +++ b/src/wallet/wallet_constructor.rs @@ -54,7 +54,9 @@ impl WalletConstructor { client.load_wallet(&self.name)?; } - Wallet::check_descriptors(&self.name, client.list_descriptors(None)?.descriptors)?; + if client.get_wallet_info()?.private_keys_enabled { + Wallet::check_descriptors(&self.name, client.list_descriptors(None)?.descriptors)?; + } client }; diff --git a/tests/wallet.rs b/tests/wallet.rs index 44dd41bb7c..d82edf11a8 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -8,6 +8,7 @@ mod create; mod dump; mod inscribe; mod inscriptions; +mod label; mod mint; mod outputs; mod receive; diff --git a/tests/wallet/label.rs b/tests/wallet/label.rs new file mode 100644 index 0000000000..65ce3127ac --- /dev/null +++ b/tests/wallet/label.rs @@ -0,0 +1,30 @@ +use super::*; + +#[test] +fn label() { + let core = mockcore::spawn(); + + let ord = TestServer::spawn_with_server_args(&core, &["--index-sats"], &[]); + + create_wallet(&core, &ord); + + core.mine_blocks(2); + + let (inscription, _reveal) = inscribe(&core, &ord); + + let output = CommandBuilder::new("wallet label") + .core(&core) + .ord(&ord) + .stdout_regex(".*") + .run_and_extract_stdout(); + + assert!( + output.contains(r#"\"name\":\"nvtcsezkbth\",\"number\":5000000000,\"rarity\":\"uncommon\""#) + ); + + assert!( + output.contains(r#"\"name\":\"nvtccadxgaz\",\"number\":10000000000,\"rarity\":\"uncommon\""#) + ); + + assert!(output.contains(&inscription.to_string())); +} diff --git a/tests/wallet/sats.rs b/tests/wallet/sats.rs index f25826fe65..44e8650bb5 100644 --- a/tests/wallet/sats.rs +++ b/tests/wallet/sats.rs @@ -52,10 +52,12 @@ fn sats_from_tsv_success() { .write("foo.tsv", "nvtcsezkbtg") .core(&core) .ord(&ord) - .run_and_deserialize_output::>(); + .run_and_deserialize_output::(); - assert_eq!(output[0].sat, "nvtcsezkbtg"); - assert_eq!(output[0].output.to_string(), format!("{second_coinbase}:0")); + assert_eq!( + output.found["nvtcsezkbtg"].to_string(), + format!("{second_coinbase}:0:1") + ); } #[test]