From 41d9474229288bc68f4166e14e2b6b817c36a057 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Mon, 15 Jan 2024 12:16:50 -0800 Subject: [PATCH] Add option to retain sat index for spent outputs (#2999) --- src/chain.rs | 7 + src/index.rs | 368 ++++++++++++++++++++++------- src/index/testing.rs | 8 +- src/index/updater.rs | 19 +- src/lib.rs | 1 - src/options.rs | 2 + src/subcommand/list.rs | 152 +++++------- src/subcommand/server.rs | 25 +- src/subcommand/wallet.rs | 18 +- src/templates/output.rs | 107 ++++++--- templates/output.html | 14 +- test-bitcoincore-rpc/src/api.rs | 8 + test-bitcoincore-rpc/src/lib.rs | 10 +- test-bitcoincore-rpc/src/server.rs | 48 +++- tests/json_api.rs | 17 +- tests/list.rs | 29 ++- tests/wallet/send.rs | 17 +- 17 files changed, 568 insertions(+), 282 deletions(-) diff --git a/src/chain.rs b/src/chain.rs index 1186ac63ad..e77e6cab0c 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -70,6 +70,13 @@ impl Chain { bitcoin::blockdata::constants::genesis_block(self.network()) } + pub(crate) fn genesis_coinbase_outpoint(self) -> OutPoint { + OutPoint { + txid: self.genesis_block().coinbase().unwrap().txid(), + vout: 0, + } + } + pub(crate) fn address_from_script( self, script: &Script, diff --git a/src/index.rs b/src/index.rs index 4b66d0fda1..d4e2e7f300 100644 --- a/src/index.rs +++ b/src/index.rs @@ -78,12 +78,6 @@ define_table! { TRANSACTION_ID_TO_RUNE, &TxidValue, u128 } define_table! { TRANSACTION_ID_TO_TRANSACTION, &TxidValue, &[u8] } define_table! { WRITE_TRANSACTION_STARTING_BLOCK_COUNT_TO_TIMESTAMP, u32, u128 } -#[derive(Debug, PartialEq)] -pub enum List { - Spent, - Unspent(Vec<(u64, u64)>), -} - #[derive(Copy, Clone)] pub(crate) enum Statistic { Schema = 0, @@ -99,6 +93,7 @@ pub(crate) enum Statistic { SatRanges = 10, UnboundInscriptions = 11, IndexTransactions = 12, + IndexSpentSats = 13, } impl Statistic { @@ -197,6 +192,7 @@ pub struct Index { height_limit: Option, index_runes: bool, index_sats: bool, + index_spent_sats: bool, index_transactions: bool, options: Options, path: PathBuf, @@ -237,10 +233,6 @@ impl Index { redb::Durability::Immediate }; - let index_runes; - let index_sats; - let index_transactions; - let index_path = path.clone(); let once = Once::new(); let progress_bar = Mutex::new(None); @@ -269,10 +261,8 @@ impl Index { { Ok(database) => { { - let tx = database.begin_read()?; - let statistics = tx.open_table(STATISTIC_TO_COUNT)?; - - let schema_version = statistics + let schema_version = database.begin_read()? + .open_table(STATISTIC_TO_COUNT)? .get(&Statistic::Schema.key())? .map(|x| x.value()) .unwrap_or(0); @@ -291,11 +281,6 @@ impl Index { cmp::Ordering::Equal => { } } - - - index_runes = Self::is_statistic_set(&statistics, Statistic::IndexRunes)?; - index_sats = Self::is_statistic_set(&statistics, Statistic::IndexSats)?; - index_transactions = Self::is_statistic_set(&statistics, Statistic::IndexTransactions)?; } database @@ -338,14 +323,35 @@ impl Index { outpoint_to_sat_ranges.insert(&OutPoint::null().store(), [].as_slice())?; } - index_runes = options.index_runes(); - index_sats = options.index_sats; - index_transactions = options.index_transactions; - - Self::set_statistic(&mut statistics, Statistic::IndexRunes, u64::from(index_runes))?; - Self::set_statistic(&mut statistics, Statistic::IndexSats, u64::from(index_sats))?; - Self::set_statistic(&mut statistics, Statistic::IndexTransactions, u64::from(index_transactions))?; - Self::set_statistic(&mut statistics, Statistic::Schema, SCHEMA_VERSION)?; + Self::set_statistic( + &mut statistics, + Statistic::IndexRunes, + u64::from(options.index_runes()), + )?; + + Self::set_statistic( + &mut statistics, + Statistic::IndexSats, + u64::from(options.index_sats || options.index_spent_sats), + )?; + + Self::set_statistic( + &mut statistics, + Statistic::IndexSpentSats, + u64::from(options.index_spent_sats), + )?; + + Self::set_statistic( + &mut statistics, + Statistic::IndexTransactions, + u64::from(options.index_transactions), + )?; + + Self::set_statistic( + &mut statistics, + Statistic::Schema, + SCHEMA_VERSION, + )?; } tx.commit()?; @@ -355,6 +361,20 @@ impl Index { Err(error) => bail!("failed to open index: {error}"), }; + let index_runes; + let index_sats; + let index_spent_sats; + let index_transactions; + + { + let tx = database.begin_read()?; + let statistics = tx.open_table(STATISTIC_TO_COUNT)?; + index_runes = Self::is_statistic_set(&statistics, Statistic::IndexRunes)?; + index_sats = Self::is_statistic_set(&statistics, Statistic::IndexSats)?; + index_spent_sats = Self::is_statistic_set(&statistics, Statistic::IndexSpentSats)?; + index_transactions = Self::is_statistic_set(&statistics, Statistic::IndexTransactions)?; + } + let genesis_block_coinbase_transaction = options.chain().genesis_block().coinbase().unwrap().clone(); @@ -368,6 +388,7 @@ impl Index { height_limit: options.height_limit, index_runes, index_sats, + index_spent_sats, index_transactions, options: options.clone(), path, @@ -1481,17 +1502,6 @@ impl Index { ) } - pub(crate) fn is_transaction_in_active_chain(&self, txid: Txid) -> Result { - Ok( - self - .client - .get_raw_transaction_info(&txid, None) - .into_option()? - .and_then(|info| info.in_active_chain) - .unwrap_or(false), - ) - } - pub(crate) fn find(&self, sat: Sat) -> Result> { let sat = sat.0; let rtx = self.begin_read()?; @@ -1573,41 +1583,60 @@ impl Index { Ok(Some(result)) } - fn list_inner(&self, outpoint: OutPointValue) -> Result>> { + pub(crate) fn list(&self, outpoint: OutPoint) -> Result>> { Ok( self .database .begin_read()? .open_table(OUTPOINT_TO_SAT_RANGES)? - .get(&outpoint)? - .map(|outpoint| outpoint.value().to_vec()), + .get(&outpoint.store())? + .map(|outpoint| outpoint.value().to_vec()) + .map(|sat_ranges| { + sat_ranges + .chunks_exact(11) + .map(|chunk| SatRange::load(chunk.try_into().unwrap())) + .collect::>() + }), ) } - pub(crate) fn list(&self, outpoint: OutPoint) -> Result> { - if !self.index_sats || outpoint == unbound_outpoint() { - return Ok(None); + pub(crate) fn is_output_spent(&self, outpoint: OutPoint) -> Result { + Ok( + outpoint != OutPoint::null() + && outpoint != self.options.chain().genesis_coinbase_outpoint() + && self + .client + .get_tx_out(&outpoint.txid, outpoint.vout, Some(false))? + .is_none(), + ) + } + + pub(crate) fn is_output_in_active_chain(&self, outpoint: OutPoint) -> Result { + if outpoint == OutPoint::null() { + return Ok(true); } - let array = outpoint.store(); + if outpoint == self.options.chain().genesis_coinbase_outpoint() { + return Ok(true); + } - let sat_ranges = self.list_inner(array)?; + let Some(info) = self + .client + .get_raw_transaction_info(&outpoint.txid, None) + .into_option()? + else { + return Ok(false); + }; - match sat_ranges { - Some(sat_ranges) => Ok(Some(List::Unspent( - sat_ranges - .chunks_exact(11) - .map(|chunk| SatRange::load(chunk.try_into().unwrap())) - .collect(), - ))), - None => { - if self.is_transaction_in_active_chain(outpoint.txid)? { - Ok(Some(List::Spent)) - } else { - Ok(None) - } - } + if !info.in_active_chain.unwrap_or_default() { + return Ok(false); + } + + if usize::try_from(outpoint.vout).unwrap() >= info.vout.len() { + return Ok(false); } + + Ok(true) } pub(crate) fn block_time(&self, height: Height) -> Result { @@ -2247,7 +2276,7 @@ mod tests { ) .unwrap() .unwrap(), - List::Unspent(vec![(0, 50 * COIN_VALUE)]) + &[(0, 50 * COIN_VALUE)], ) } @@ -2257,7 +2286,7 @@ mod tests { let txid = context.mine_blocks(1)[0].txdata[0].txid(); assert_eq!( context.index.list(OutPoint::new(txid, 0)).unwrap().unwrap(), - List::Unspent(vec![(50 * COIN_VALUE, 100 * COIN_VALUE)]) + &[(50 * COIN_VALUE, 100 * COIN_VALUE)], ) } @@ -2278,12 +2307,12 @@ mod tests { assert_eq!( context.index.list(OutPoint::new(txid, 0)).unwrap().unwrap(), - List::Unspent(vec![(50 * COIN_VALUE, 75 * COIN_VALUE)]) + &[(50 * COIN_VALUE, 75 * COIN_VALUE)], ); assert_eq!( context.index.list(OutPoint::new(txid, 1)).unwrap().unwrap(), - List::Unspent(vec![(75 * COIN_VALUE, 100 * COIN_VALUE)]) + &[(75 * COIN_VALUE, 100 * COIN_VALUE)], ); } @@ -2303,10 +2332,10 @@ mod tests { assert_eq!( context.index.list(OutPoint::new(txid, 0)).unwrap().unwrap(), - List::Unspent(vec![ + &[ (50 * COIN_VALUE, 100 * COIN_VALUE), (100 * COIN_VALUE, 150 * COIN_VALUE) - ]), + ], ); } @@ -2326,12 +2355,12 @@ mod tests { assert_eq!( context.index.list(OutPoint::new(txid, 0)).unwrap().unwrap(), - List::Unspent(vec![(50 * COIN_VALUE, 7499999995)]), + &[(50 * COIN_VALUE, 7499999995)], ); assert_eq!( context.index.list(OutPoint::new(txid, 1)).unwrap().unwrap(), - List::Unspent(vec![(7499999995, 9999999990)]), + &[(7499999995, 9999999990)], ); assert_eq!( @@ -2340,7 +2369,7 @@ mod tests { .list(OutPoint::new(coinbase_txid, 0)) .unwrap() .unwrap(), - List::Unspent(vec![(10000000000, 15000000000), (9999999990, 10000000000)]) + &[(10000000000, 15000000000), (9999999990, 10000000000)], ); } @@ -2370,11 +2399,11 @@ mod tests { .list(OutPoint::new(coinbase_txid, 0)) .unwrap() .unwrap(), - List::Unspent(vec![ + &[ (15000000000, 20000000000), (9999999990, 10000000000), (14999999990, 15000000000) - ]) + ], ); } @@ -2393,7 +2422,7 @@ mod tests { assert_eq!( context.index.list(OutPoint::new(txid, 0)).unwrap().unwrap(), - List::Unspent(Vec::new()) + &[], ); } @@ -2420,7 +2449,7 @@ mod tests { assert_eq!( context.index.list(OutPoint::new(txid, 0)).unwrap().unwrap(), - List::Unspent(Vec::new()) + &[], ); } @@ -2435,10 +2464,7 @@ mod tests { }); context.mine_blocks(1); let txid = context.rpc_server.tx(1, 0).txid(); - assert_eq!( - context.index.list(OutPoint::new(txid, 0)).unwrap().unwrap(), - List::Spent, - ); + assert_matches!(context.index.list(OutPoint::new(txid, 0)).unwrap(), None); } #[test] @@ -3012,10 +3038,7 @@ mod tests { .args(["--index-sats", "--first-inscription-height", "10"]) .build(); - let null_ranges = || match context.index.list(OutPoint::null()).unwrap().unwrap() { - List::Unspent(ranges) => ranges, - _ => panic!(), - }; + let null_ranges = || context.index.list(OutPoint::null()).unwrap().unwrap(); assert!(null_ranges().is_empty()); @@ -5838,4 +5861,187 @@ mod tests { assert_eq!(sat, entry.sat); } } + + #[test] + fn index_spent_sats_retains_spent_sat_range_entries() { + let ranges = { + let context = Context::builder().arg("--index-sats").build(); + + context.mine_blocks(1); + + let outpoint = OutPoint { + txid: context.rpc_server.tx(1, 0).into(), + vout: 0, + }; + + let ranges = context.index.list(outpoint).unwrap().unwrap(); + + assert!(!ranges.is_empty()); + + context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0, Default::default())], + ..Default::default() + }); + + context.mine_blocks(1); + + assert!(context.index.list(outpoint).unwrap().is_none()); + + ranges + }; + + { + let context = Context::builder() + .arg("--index-sats") + .arg("--index-spent-sats") + .build(); + + context.mine_blocks(1); + + let outpoint = OutPoint { + txid: context.rpc_server.tx(1, 0).into(), + vout: 0, + }; + + let unspent_ranges = context.index.list(outpoint).unwrap().unwrap(); + + assert!(!unspent_ranges.is_empty()); + + assert_eq!(unspent_ranges, ranges); + + context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0, Default::default())], + ..Default::default() + }); + + context.mine_blocks(1); + + let spent_ranges = context.index.list(outpoint).unwrap().unwrap(); + + assert_eq!(spent_ranges, ranges); + } + } + + #[test] + fn index_spent_sats_implies_index_sats() { + let context = Context::builder().arg("--index-spent-sats").build(); + + context.mine_blocks(1); + + let outpoint = OutPoint { + txid: context.rpc_server.tx(1, 0).into(), + vout: 0, + }; + + context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0, Default::default())], + ..Default::default() + }); + + context.mine_blocks(1); + + assert!(context.index.list(outpoint).unwrap().is_some()); + } + + #[test] + fn spent_sats_are_retained_after_flush() { + let context = Context::builder().arg("--index-spent-sats").build(); + + context.mine_blocks(1); + + let txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0, Default::default())], + ..Default::default() + }); + + context.mine_blocks_with_update(1, false); + + let outpoint = OutPoint { txid, vout: 0 }; + + context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(2, 1, 0, Default::default())], + ..Default::default() + }); + + context.mine_blocks(1); + + assert!(context.index.list(outpoint).unwrap().is_some()); + } + + #[test] + fn is_output_spent() { + let context = Context::builder().build(); + + assert!(!context.index.is_output_spent(OutPoint::null()).unwrap()); + assert!(!context + .index + .is_output_spent(Chain::Mainnet.genesis_coinbase_outpoint()) + .unwrap()); + + context.mine_blocks(1); + + assert!(!context + .index + .is_output_spent(OutPoint { + txid: context.rpc_server.tx(1, 0).txid(), + vout: 0, + }) + .unwrap()); + + context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0, Default::default())], + ..Default::default() + }); + + context.mine_blocks(1); + + assert!(context + .index + .is_output_spent(OutPoint { + txid: context.rpc_server.tx(1, 0).txid(), + vout: 0, + }) + .unwrap()); + } + + #[test] + fn is_output_in_active_chain() { + let context = Context::builder().build(); + + assert!(context + .index + .is_output_in_active_chain(OutPoint::null()) + .unwrap()); + + assert!(context + .index + .is_output_in_active_chain(Chain::Mainnet.genesis_coinbase_outpoint()) + .unwrap()); + + context.mine_blocks(1); + + assert!(context + .index + .is_output_in_active_chain(OutPoint { + txid: context.rpc_server.tx(1, 0).txid(), + vout: 0, + }) + .unwrap()); + + assert!(!context + .index + .is_output_in_active_chain(OutPoint { + txid: context.rpc_server.tx(1, 0).txid(), + vout: 1, + }) + .unwrap()); + + assert!(!context + .index + .is_output_in_active_chain(OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }) + .unwrap()); + } } diff --git a/src/index/testing.rs b/src/index/testing.rs index 462f07336c..199dfe7974 100644 --- a/src/index/testing.rs +++ b/src/index/testing.rs @@ -82,8 +82,14 @@ impl Context { } pub(crate) fn mine_blocks(&self, n: u64) -> Vec { + self.mine_blocks_with_update(n, true) + } + + pub(crate) fn mine_blocks_with_update(&self, n: u64, update: bool) -> Vec { let blocks = self.rpc_server.mine_blocks(n); - self.index.update().unwrap(); + if update { + self.index.update().unwrap(); + } blocks } diff --git a/src/index/updater.rs b/src/index/updater.rs index 7aa89768b8..bfc10b0577 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -471,16 +471,23 @@ impl<'index> Updater<'_> { for input in &tx.input { let key = input.previous_output.store(); - let sat_ranges = match self.range_cache.remove(&key) { + let sat_ranges = match if index.index_spent_sats { + self.range_cache.get(&key).cloned() + } else { + self.range_cache.remove(&key) + } { Some(sat_ranges) => { self.outputs_cached += 1; sat_ranges } - None => outpoint_to_sat_ranges - .remove(&key)? - .ok_or_else(|| anyhow!("Could not find outpoint {} in index", input.previous_output))? - .value() - .to_vec(), + None => if index.index_spent_sats { + outpoint_to_sat_ranges.get(&key)? + } else { + outpoint_to_sat_ranges.remove(&key)? + } + .ok_or_else(|| anyhow!("Could not find outpoint {} in index", input.previous_output))? + .value() + .to_vec(), }; for chunk in sat_ranges.chunks_exact(11) { diff --git a/src/lib.rs b/src/lib.rs index 27662d0f9e..4bdb117364 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,6 @@ use { deserialize_from_str::DeserializeFromStr, epoch::Epoch, height::Height, - index::List, inscriptions::{media, teleburn, Charm, Media, ParsedEnvelope}, outgoing::Outgoing, representation::Representation, diff --git a/src/options.rs b/src/options.rs index ec6261f0f8..4d91b3181d 100644 --- a/src/options.rs +++ b/src/options.rs @@ -49,6 +49,8 @@ pub struct Options { pub(crate) index_runes: bool, #[arg(long, help = "Track location of all satoshis.")] pub(crate) index_sats: bool, + #[arg(long, help = "Keep sat index entries of spent outputs.")] + pub(crate) index_spent_sats: bool, #[arg(long, help = "Store transactions in index.")] pub(crate) index_transactions: bool, #[arg( diff --git a/src/subcommand/list.rs b/src/subcommand/list.rs index 8de7f8e0ea..d42b570443 100644 --- a/src/subcommand/list.rs +++ b/src/subcommand/list.rs @@ -8,13 +8,18 @@ pub(crate) struct List { #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct Output { - pub output: OutPoint, - pub start: u64, + pub ranges: Option>, + pub spent: bool, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct Range { pub end: u64, - pub size: u64, + pub name: String, pub offset: u64, pub rarity: Rarity, - pub name: String, + pub size: u64, + pub start: u64, } impl List { @@ -27,52 +32,35 @@ impl List { index.update()?; - match index.list(self.outpoint)? { - Some(crate::index::List::Unspent(ranges)) => { - let mut outputs = Vec::new(); - for Output { - output, - start, - end, - size, - offset, - rarity, - name, - } in list(self.outpoint, ranges) - { - outputs.push(Output { - output, - start, - end, - size, - offset, - rarity, - name, - }); - } - - Ok(Some(Box::new(outputs))) - } - Some(crate::index::List::Spent) => Err(anyhow!("output spent.")), - None => Err(anyhow!("output not found")), + ensure! { + index.is_output_in_active_chain(self.outpoint)?, + "output not found" } + + let ranges = index.list(self.outpoint)?; + + let spent = index.is_output_spent(self.outpoint)?; + + Ok(Some(Box::new(Output { + spent, + ranges: ranges.map(output_ranges), + }))) } } -fn list(outpoint: OutPoint, ranges: Vec<(u64, u64)>) -> Vec { +fn output_ranges(ranges: Vec<(u64, u64)>) -> Vec { let mut offset = 0; ranges .into_iter() .map(|(start, end)| { let size = end - start; - let output = Output { - output: outpoint, - start, + let output = Range { end, - size, - offset, name: Sat(start).name(), + offset, rarity: Sat(start).rarity(), + size, + start, }; offset += size; @@ -86,69 +74,39 @@ fn list(outpoint: OutPoint, ranges: Vec<(u64, u64)>) -> Vec { mod tests { use super::*; - fn output( - output: OutPoint, - start: u64, - end: u64, - size: u64, - offset: u64, - rarity: Rarity, - name: String, - ) -> super::Output { - super::Output { - output, - start, - end, - size, - offset, - name, - rarity, - } - } - #[test] fn list_ranges() { - let outpoint = - OutPoint::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:5") - .unwrap(); - let ranges = vec![ - (50 * COIN_VALUE, 55 * COIN_VALUE), - (10, 100), - (1050000000000000, 1150000000000000), - ]; assert_eq!( - list(outpoint, ranges), + output_ranges(vec![ + (50 * COIN_VALUE, 55 * COIN_VALUE), + (10, 100), + (1050000000000000, 1150000000000000), + ]), vec![ - output( - OutPoint::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:5") - .unwrap(), - 50 * COIN_VALUE, - 55 * COIN_VALUE, - 5 * COIN_VALUE, - 0, - Rarity::Uncommon, - "nvtcsezkbth".to_string() - ), - output( - OutPoint::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:5") - .unwrap(), - 10, - 100, - 90, - 5 * COIN_VALUE, - Rarity::Common, - "nvtdijuwxlf".to_string() - ), - output( - OutPoint::from_str("4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:5") - .unwrap(), - 1050000000000000, - 1150000000000000, - 100000000000000, - 5 * COIN_VALUE + 90, - Rarity::Epic, - "gkjbdrhkfqf".to_string() - ) + Range { + end: 55 * COIN_VALUE, + name: "nvtcsezkbth".to_string(), + offset: 0, + rarity: Rarity::Uncommon, + size: 5 * COIN_VALUE, + start: 50 * COIN_VALUE, + }, + Range { + end: 100, + name: "nvtdijuwxlf".to_string(), + offset: 5 * COIN_VALUE, + rarity: Rarity::Common, + size: 90, + start: 10, + }, + Range { + end: 1150000000000000, + name: "gkjbdrhkfqf".to_string(), + offset: 5 * COIN_VALUE + 90, + rarity: Rarity::Epic, + size: 100000000000000, + start: 1050000000000000, + } ] ) } diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index b34b1c4d72..30d7e8ac48 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -545,14 +545,14 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { - let list = index.list(outpoint)?; + let sat_ranges = index.list(outpoint)?; let indexed; let output = if outpoint == OutPoint::null() || outpoint == unbound_outpoint() { let mut value = 0; - if let Some(List::Unspent(ranges)) = &list { + if let Some(ranges) = &sat_ranges { for (start, end) in ranges { value += end - start; } @@ -580,28 +580,32 @@ impl Server { let runes = index.get_rune_balances_for_outpoint(outpoint)?; + let spent = index.is_output_spent(outpoint)?; + Ok(if accept_json { Json(OutputJson::new( - outpoint, - list, server_config.chain, - output, inscriptions, + outpoint, + output, indexed, runes .into_iter() .map(|(spaced_rune, pile)| (spaced_rune.rune, pile.amount)) .collect(), + sat_ranges, + spent, )) .into_response() } else { OutputHtml { - outpoint, - inscriptions, - list, chain: server_config.chain, + inscriptions, + outpoint, output, runes, + sat_ranges, + spent, } .page(server_config) .into_response() @@ -2599,6 +2603,7 @@ mod tests { sat_ranges: None, indexed: true, inscriptions: Vec::new(), + spent: false, runes: vec![(Rune(RUNE), 340282366920938463463374607431768211455)] .into_iter() .collect(), @@ -2858,6 +2863,7 @@ mod tests {
value
5000000000
script pubkey
OP_PUSHBYTES_65 [[:xdigit:]]{{130}} OP_CHECKSIG
transaction
{txid}
+
spent
false

1 Sat Range

    @@ -2879,6 +2885,7 @@ mod tests {
    value
    5000000000
    script pubkey
    OP_PUSHBYTES_65 [[:xdigit:]]{{130}} OP_CHECKSIG
    transaction
    {txid}
    +
    spent
    false
    .*" ), ); @@ -2896,6 +2903,7 @@ mod tests {
    value
    0
    script pubkey
    transaction
    {txid}
    +
    spent
    false

    0 Sat Ranges

      @@ -2921,6 +2929,7 @@ mod tests {
      value
      5000000000
      script pubkey
      transaction
      {txid}
      +
      spent
      false

      1 Sat Range

        diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index 64641d4383..39c96a6fbf 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -123,11 +123,19 @@ pub(crate) fn get_unspent_output_ranges( index: &Index, ) -> Result)>> { get_unspent_outputs(client, index)? - .into_keys() - .map(|outpoint| match index.list(outpoint)? { - Some(List::Unspent(sat_ranges)) => Ok((outpoint, sat_ranges)), - Some(List::Spent) => bail!("output {outpoint} in wallet but is spent according to index"), - None => bail!("index has not seen {outpoint}"), + .iter() + .map(|(outpoint, value)| match index.list(*outpoint)? { + Some(sat_ranges) => { + assert_eq!( + sat_ranges + .iter() + .map(|(start, end)| end - start) + .sum::(), + value.to_sat() + ); + Ok((*outpoint, sat_ranges)) + } + None => bail!("index does not have sat index"), }) .collect() } diff --git a/src/templates/output.rs b/src/templates/output.rs index 73a566038a..ed18801d3f 100644 --- a/src/templates/output.rs +++ b/src/templates/output.rs @@ -2,51 +2,52 @@ use super::*; #[derive(Boilerplate)] pub(crate) struct OutputHtml { - pub(crate) outpoint: OutPoint, - pub(crate) list: Option, pub(crate) chain: Chain, - pub(crate) output: TxOut, pub(crate) inscriptions: Vec, + pub(crate) outpoint: OutPoint, + pub(crate) output: TxOut, pub(crate) runes: Vec<(SpacedRune, Pile)>, + pub(crate) sat_ranges: Option>, + pub(crate) spent: bool, } #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct OutputJson { - pub value: u64, - pub script_pubkey: String, pub address: Option, - pub transaction: String, - pub sat_ranges: Option>, pub indexed: bool, pub inscriptions: Vec, pub runes: BTreeMap, + pub sat_ranges: Option>, + pub script_pubkey: String, + pub spent: bool, + pub transaction: String, + pub value: u64, } impl OutputJson { pub fn new( - outpoint: OutPoint, - list: Option, chain: Chain, - output: TxOut, inscriptions: Vec, + outpoint: OutPoint, + output: TxOut, indexed: bool, runes: BTreeMap, + sat_ranges: Option>, + spent: bool, ) -> Self { Self { - value: output.value, - runes, - script_pubkey: output.script_pubkey.to_asm_string(), address: chain .address_from_script(&output.script_pubkey) .ok() .map(|address| address.to_string()), - transaction: outpoint.txid.to_string(), - sat_ranges: match list { - Some(List::Unspent(ranges)) => Some(ranges), - _ => None, - }, indexed, inscriptions, + runes, + sat_ranges, + script_pubkey: output.script_pubkey.to_asm_string(), + spent, + transaction: outpoint.txid.to_string(), + value: output.value, } } } @@ -68,15 +69,13 @@ mod tests { fn unspent_output() { assert_regex_match!( OutputHtml { + chain: Chain::Mainnet, inscriptions: Vec::new(), outpoint: outpoint(1), - list: Some(List::Unspent(vec![(0, 1), (1, 3)])), - chain: Chain::Mainnet, - output: TxOut { - value: 3, - script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), - }, + output: TxOut { value: 3, script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), }, runes: Vec::new(), + sat_ranges: Some(vec![(0, 1), (1, 3)]), + spent: false, }, "

        Output 1{64}:1

        @@ -85,6 +84,7 @@ mod tests {
        script pubkey
        OP_DUP OP_HASH160 OP_PUSHBYTES_20 0{40} OP_EQUALVERIFY OP_CHECKSIG
        address
        1111111111111111111114oLvT2
        transaction
        1{64}
        +
        spent
        false

        2 Sat Ranges

          @@ -100,15 +100,16 @@ mod tests { fn spent_output() { assert_regex_match!( OutputHtml { + chain: Chain::Mainnet, inscriptions: Vec::new(), outpoint: outpoint(1), - list: Some(List::Spent), - chain: Chain::Mainnet, output: TxOut { value: 1, script_pubkey: script::Builder::new().push_int(0).into_script(), }, runes: Vec::new(), + sat_ranges: None, + spent: true, }, "

          Output 1{64}:1

          @@ -116,26 +117,55 @@ mod tests {
          value
          1
          script pubkey
          OP_0
          transaction
          1{64}
          +
          spent
          true
          -

          Output has been spent.

          " .unindent() ); } #[test] - fn no_list() { + fn spent_output_with_ranges() { assert_regex_match!( OutputHtml { + chain: Chain::Mainnet, inscriptions: Vec::new(), outpoint: outpoint(1), - list: None, + output: TxOut { value: 3, script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), }, + runes: Vec::new(), + sat_ranges: Some(vec![(0, 1), (1, 3)]), + spent: true, + }, + " +

          Output 1{64}:1

          +
          +
          value
          3
          +
          script pubkey
          OP_DUP OP_HASH160 OP_PUSHBYTES_20 0{40} OP_EQUALVERIFY OP_CHECKSIG
          +
          address
          1111111111111111111114oLvT2
          +
          transaction
          1{64}
          +
          spent
          true
          +
          +

          2 Sat Ranges

          + + " + .unindent() + ); + } + + #[test] + fn no_list() { + assert_regex_match!( + OutputHtml { chain: Chain::Mainnet, - output: TxOut { - value: 3, - script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), - }, + inscriptions: Vec::new(), + outpoint: outpoint(1), + output: TxOut { value: 3, script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), }, runes: Vec::new(), + sat_ranges: None, + spent: false, } .to_string(), " @@ -145,6 +175,7 @@ mod tests {
          script pubkey
          OP_DUP OP_HASH160 OP_PUSHBYTES_20 0{40} OP_EQUALVERIFY OP_CHECKSIG
          address
          1111111111111111111114oLvT2
          transaction
          1{64}
          +
          spent
          false
          " .unindent() @@ -155,15 +186,16 @@ mod tests { fn with_inscriptions() { assert_regex_match!( OutputHtml { + chain: Chain::Mainnet, inscriptions: vec![inscription_id(1)], outpoint: outpoint(1), - list: None, - chain: Chain::Mainnet, output: TxOut { value: 3, script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), }, runes: Vec::new(), + sat_ranges: None, + spent: false, }, "

          Output 1{64}:1

          @@ -183,10 +215,9 @@ mod tests { fn with_runes() { assert_regex_match!( OutputHtml { + chain: Chain::Mainnet, inscriptions: Vec::new(), outpoint: outpoint(1), - list: None, - chain: Chain::Mainnet, output: TxOut { value: 3, script_pubkey: ScriptBuf::new_p2pkh(&PubkeyHash::all_zeros()), @@ -202,6 +233,8 @@ mod tests { symbol: None, } )], + sat_ranges: None, + spent: false, }, "

          Output 1{64}:1

          diff --git a/templates/output.html b/templates/output.html index bb1918d9f6..e5e6f55d60 100644 --- a/templates/output.html +++ b/templates/output.html @@ -31,13 +31,12 @@

          Output {{self.outpoint}}

          address
          {{ address }}
          %% }
          transaction
          {{ self.outpoint.txid }}
          +
          spent
          {{ self.spent }}
          -%% if let Some(list) = &self.list { -%% match list { -%% List::Unspent(ranges) => { -

          {{"Sat Range".tally(ranges.len())}}

          +%% if let Some(sat_ranges) = &self.sat_ranges { +

          {{"Sat Range".tally(sat_ranges.len())}}

            -%% for (start, end) in ranges { +%% for (start, end) in sat_ranges { %% if end - start == 1 {
          • {{start}}
          • %% } else { @@ -46,8 +45,3 @@

            {{"Sat Range".tally(ranges.len())}}

            %% }
          %% } -%% List::Spent => { -

          Output has been spent.

          -%% } -%% } -%% } diff --git a/test-bitcoincore-rpc/src/api.rs b/test-bitcoincore-rpc/src/api.rs index ecf763ef1b..8bef7734f2 100644 --- a/test-bitcoincore-rpc/src/api.rs +++ b/test-bitcoincore-rpc/src/api.rs @@ -28,6 +28,14 @@ pub trait Api { #[rpc(name = "getblockcount")] fn get_block_count(&self) -> Result; + #[rpc(name = "gettxout")] + fn get_tx_out( + &self, + txid: Txid, + vout: u32, + include_mempool: Option, + ) -> Result, jsonrpc_core::Error>; + #[rpc(name = "getwalletinfo")] fn get_wallet_info(&self) -> Result; diff --git a/test-bitcoincore-rpc/src/lib.rs b/test-bitcoincore-rpc/src/lib.rs index 4263313822..51eee13bf4 100644 --- a/test-bitcoincore-rpc/src/lib.rs +++ b/test-bitcoincore-rpc/src/lib.rs @@ -19,11 +19,11 @@ use { bitcoincore_rpc::json::{ Bip125Replaceable, CreateRawTransactionInput, Descriptor, EstimateMode, GetBalancesResult, GetBalancesResultEntry, GetBlockHeaderResult, GetBlockchainInfoResult, GetDescriptorInfoResult, - GetNetworkInfoResult, GetRawTransactionResult, GetTransactionResult, - GetTransactionResultDetail, GetTransactionResultDetailCategory, GetWalletInfoResult, - ImportDescriptors, ImportMultiResult, ListDescriptorsResult, ListTransactionResult, - ListUnspentResultEntry, LoadWalletResult, SignRawTransactionInput, SignRawTransactionResult, - Timestamp, WalletTxInfo, + GetNetworkInfoResult, GetRawTransactionResult, GetRawTransactionResultVout, + GetRawTransactionResultVoutScriptPubKey, GetTransactionResult, GetTransactionResultDetail, + GetTransactionResultDetailCategory, GetTxOutResult, GetWalletInfoResult, ImportDescriptors, + ImportMultiResult, ListDescriptorsResult, ListTransactionResult, ListUnspentResultEntry, + LoadWalletResult, SignRawTransactionInput, SignRawTransactionResult, Timestamp, WalletTxInfo, }, jsonrpc_core::{IoHandler, Value}, jsonrpc_http_server::{CloseHandle, ServerBuilder}, diff --git a/test-bitcoincore-rpc/src/server.rs b/test-bitcoincore-rpc/src/server.rs index f678329ddc..2e60d30358 100644 --- a/test-bitcoincore-rpc/src/server.rs +++ b/test-bitcoincore-rpc/src/server.rs @@ -166,6 +166,34 @@ impl Api for Server { ) } + fn get_tx_out( + &self, + txid: Txid, + vout: u32, + _include_mempool: Option, + ) -> Result, jsonrpc_core::Error> { + Ok( + self + .state() + .utxos + .get(&OutPoint { txid, vout }) + .map(|&value| GetTxOutResult { + bestblock: bitcoin::BlockHash::all_zeros(), + confirmations: 0, + value, + script_pub_key: GetRawTransactionResultVoutScriptPubKey { + asm: String::new(), + hex: Vec::new(), + req_sigs: None, + type_: None, + addresses: Vec::new(), + address: None, + }, + coinbase: false, + }), + ) + } + fn get_wallet_info(&self) -> Result { if let Some(wallet_name) = self.state().loaded_wallets.first().cloned() { Ok(GetWalletInfoResult { @@ -484,7 +512,7 @@ impl Api for Server { assert_eq!(blockhash, None, "Blockhash param is unsupported"); if verbose.unwrap_or(false) { match self.state().transactions.get(&txid) { - Some(_) => Ok( + Some(transaction) => Ok( serde_json::to_value(GetRawTransactionResult { in_active_chain: Some(true), hex: Vec::new(), @@ -495,7 +523,23 @@ impl Api for Server { version: 2, locktime: 0, vin: Vec::new(), - vout: Vec::new(), + vout: transaction + .output + .iter() + .enumerate() + .map(|(n, output)| GetRawTransactionResultVout { + n: n.try_into().unwrap(), + value: Amount::from_sat(output.value), + script_pub_key: GetRawTransactionResultVoutScriptPubKey { + asm: String::new(), + hex: Vec::new(), + req_sigs: None, + type_: None, + addresses: Vec::new(), + address: None, + }, + }) + .collect(), blockhash: None, confirmations: Some(1), time: None, diff --git a/tests/json_api.rs b/tests/json_api.rs index a950bc5b49..7702646d38 100644 --- a/tests/json_api.rs +++ b/tests/json_api.rs @@ -330,15 +330,7 @@ fn get_output() { pretty_assert_eq!( output_json, OutputJson { - value: 3 * 50 * COIN_VALUE, - script_pubkey: "".to_string(), address: None, - transaction: txid.to_string(), - sat_ranges: Some(vec![ - (5000000000, 10000000000,), - (10000000000, 15000000000,), - (15000000000, 20000000000,), - ],), inscriptions: vec![ InscriptionId { txid, index: 0 }, InscriptionId { txid, index: 1 }, @@ -346,6 +338,15 @@ fn get_output() { ], indexed: true, runes: BTreeMap::new(), + sat_ranges: Some(vec![ + (5000000000, 10000000000,), + (10000000000, 15000000000,), + (15000000000, 20000000000,), + ],), + script_pubkey: "".to_string(), + spent: false, + transaction: txid.to_string(), + value: 3 * 50 * COIN_VALUE, } ); } diff --git a/tests/list.rs b/tests/list.rs index d66fd47caa..14734a7f5f 100644 --- a/tests/list.rs +++ b/tests/list.rs @@ -1,4 +1,7 @@ -use {super::*, ord::subcommand::list::Output}; +use { + super::*, + ord::subcommand::list::{Output, Range}, +}; #[test] fn output_found() { @@ -7,21 +10,21 @@ fn output_found() { "--index-sats list 4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:0", ) .rpc_server(&rpc_server) - .run_and_deserialize_output::>(); + .run_and_deserialize_output::(); assert_eq!( output, - vec![Output { - output: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:0" - .parse() - .unwrap(), - start: 0, - end: 50 * COIN_VALUE, - size: 50 * COIN_VALUE, - offset: 0, - rarity: "mythic".parse().unwrap(), - name: "nvtdijuwxlp".into(), - }] + Output { + ranges: Some(vec![Range { + end: 50 * COIN_VALUE, + name: "nvtdijuwxlp".into(), + offset: 0, + rarity: "mythic".parse().unwrap(), + size: 50 * COIN_VALUE, + start: 0, + }]), + spent: false, + } ); } diff --git a/tests/wallet/send.rs b/tests/wallet/send.rs index 0ffe1570ab..c8fb2d73f8 100644 --- a/tests/wallet/send.rs +++ b/tests/wallet/send.rs @@ -237,15 +237,7 @@ fn splitting_merged_inscriptions_is_possible() { pretty_assert_eq!( output_json, OutputJson { - value: 3 * 50 * COIN_VALUE, - script_pubkey: "".to_string(), address: None, - transaction: reveal_txid.to_string(), - sat_ranges: Some(vec![ - (5000000000, 10000000000,), - (10000000000, 15000000000,), - (15000000000, 20000000000,), - ],), inscriptions: vec![ InscriptionId { txid: reveal_txid, @@ -262,6 +254,15 @@ fn splitting_merged_inscriptions_is_possible() { ], indexed: true, runes: BTreeMap::new(), + sat_ranges: Some(vec![ + (5000000000, 10000000000,), + (10000000000, 15000000000,), + (15000000000, 20000000000,), + ],), + script_pubkey: "".to_string(), + spent: false, + transaction: reveal_txid.to_string(), + value: 3 * 50 * COIN_VALUE, } );