diff --git a/app/rpc_server.rs b/app/rpc_server.rs index faff067e..808c42a5 100644 --- a/app/rpc_server.rs +++ b/app/rpc_server.rs @@ -177,6 +177,39 @@ impl rpc_api::node::RpcServer Ok(Some(block_hash)) } + async fn get_block_hash( + &self, + height: u32, + ) -> RpcResult> { + self.app.node.try_get_block_hash(height).map_err(custom_err) + } + + async fn get_block_index( + &self, + block_hash: thunder::types::BlockHash, + ) -> RpcResult { + let body = self.app.node.get_body(block_hash).map_err(custom_err)?; + let txs = body + .transactions + .iter() + .map(|tx| rpc_api::node::BlockIndexTx { + txid: tx.txid(), + size: tx.canonical_size(), + raw: const_hex::encode(tx.canonical_encoding()), + }) + .collect(); + let events = self + .app + .node + .get_block_index_events(block_hash) + .map_err(custom_err)?; + Ok(rpc_api::node::GetBlockIndexResponse { + txs, + deposits: events.deposits, + bundle_spends: events.bundle_spends, + }) + } + async fn get_bmm_inclusions( &self, block_hash: thunder::types::BlockHash, diff --git a/cli/lib.rs b/cli/lib.rs index d324a872..fba3f117 100644 --- a/cli/lib.rs +++ b/cli/lib.rs @@ -80,6 +80,12 @@ pub enum Command { GetBlock { block_hash: thunder::types::BlockHash, }, + /// Get the block hash at the specified height, if it exists + GetBlockHash { height: u32 }, + /// Get everything about a block that its body does not carry + GetBlockIndex { + block_hash: thunder::types::BlockHash, + }, /// Assemble a block to blind merge mine, without requesting BMM for it GetBlockTemplate, /// Get mainchain blocks that commit to a specified block hash @@ -249,6 +255,14 @@ where let block_hash = rpc_client.get_best_sidechain_block_hash().await?; serde_json::to_string_pretty(&block_hash)? } + Command::GetBlockHash { height } => { + let block_hash = rpc_client.get_block_hash(height).await?; + serde_json::to_string_pretty(&block_hash)? + } + Command::GetBlockIndex { block_hash } => { + let block_index = rpc_client.get_block_index(block_hash).await?; + serde_json::to_string_pretty(&block_index)? + } Command::GetBlockTemplate => { let template = rpc_client.get_block_template().await?; serde_json::to_string_pretty(&template)? diff --git a/lib/node/error.rs b/lib/node/error.rs index 1d30f085..d17af937 100644 --- a/lib/node/error.rs +++ b/lib/node/error.rs @@ -4,7 +4,7 @@ use transitive::Transitive; use crate::{ archive, mempool, net, state, - types::{AmountOverflowError, AmountUnderflowError, proto}, + types::{AmountOverflowError, AmountUnderflowError, BlockHash, proto}, }; pub mod mainchain_task { @@ -176,6 +176,8 @@ pub enum Error { Net(#[from] Box), #[error("net task error")] NetTask(#[source] Box), + #[error("block {block_hash} is not in the current chain")] + NotInCurrentChain { block_hash: BlockHash }, #[error("peer info stream closed")] PeerInfoRxClosed, #[error("Receive mainchain task response cancelled")] diff --git a/lib/node/mod.rs b/lib/node/mod.rs index 47ff4305..50fab2a7 100644 --- a/lib/node/mod.rs +++ b/lib/node/mod.rs @@ -9,7 +9,7 @@ use std::{ use bitcoin::amount::CheckedSum; use fallible_iterator::{FallibleIterator, IteratorExt}; use futures::Stream; -use sneed::{DbError, Env, EnvError, RwTxnError}; +use sneed::{DbError, Env, EnvError, RoTxn, RwTxnError}; use tokio::sync::Mutex; use tonic::transport::Channel; @@ -20,9 +20,10 @@ use crate::{ state::State, types::{ Accumulator, Address, AmountOverflowError, AmountUnderflowError, - Authorized, AuthorizedTransaction, BlockHash, BmmResult, Body, - FilledTransaction, GetValue, Header, Network, OutPoint, OutPointKey, - Output, SpentOutput, Tip, Transaction, Txid, WithdrawalBundle, + Authorized, AuthorizedTransaction, BlockHash, BlockIndexEvents, + BmmResult, Body, FilledTransaction, GetValue, Header, Network, + OutPoint, OutPointKey, Output, SpentOutput, Tip, Transaction, Txid, + WithdrawalBundle, net::{Peer, PeerAddress, ResolvedPeerAddress}, proto::{self, mainchain}, }, @@ -373,22 +374,36 @@ where Ok(self.archive.get_header(&txn, block_hash)?) } - /// Get the block hash at the specified height in the current chain, - /// if it exists - pub fn try_get_block_hash( + /// Get the coin movements that the block applied outside its body + pub fn get_block_index_events( + &self, + block_hash: BlockHash, + ) -> Result { + let rotxn = self.env.read_txn().map_err(EnvError::from)?; + let height = self.archive.get_height(&rotxn, block_hash)?; + // The events are keyed by height, so a block off the current chain + // would read another block's events. + if self.try_get_block_hash_read(&rotxn, height)? != Some(block_hash) { + return Err(Error::NotInCurrentChain { block_hash }); + } + let events = self.state.get_block_index_events(&rotxn, height)?; + Ok(events) + } + + fn try_get_block_hash_read( &self, + rotxn: &RoTxn, height: u32, ) -> Result, Error> { - let rotxn = self.env.read_txn().map_err(EnvError::from)?; - let Some(tip) = self.state.try_get_tip(&rotxn)? else { + let Some(tip) = self.state.try_get_tip(rotxn)? else { return Ok(None); }; - let Some(tip_height) = self.state.try_get_height(&rotxn)? else { + let Some(tip_height) = self.state.try_get_height(rotxn)? else { return Ok(None); }; if tip_height >= height { self.archive - .ancestors(&rotxn, tip) + .ancestors(rotxn, tip) .nth((tip_height - height) as usize) .map_err(Error::from) } else { @@ -396,6 +411,16 @@ where } } + /// Get the block hash at the specified height in the current chain, + /// if it exists + pub fn try_get_block_hash( + &self, + height: u32, + ) -> Result, Error> { + let rotxn = self.env.read_txn().map_err(EnvError::from)?; + self.try_get_block_hash_read(&rotxn, height) + } + pub fn try_get_body( &self, block_hash: BlockHash, diff --git a/lib/state/mod.rs b/lib/state/mod.rs index b087e011..21c4704d 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -15,12 +15,13 @@ use sneed::{ use crate::{ types::{ Accumulator, Address, AmountOverflowError, AmountUnderflowError, - Authorization, Authorized, AuthorizedTransaction, BlockHash, Body, - FilledTransaction, GetAddress, GetValue, Header, InPoint, M6id, - MerkleRoot, OutPoint, OutPointKey, Output, PointedOutput, - PointedOutputRef, SpentOutput, Transaction, UtreexoNodeHash, - UtreexoProof, VERSION, Verify, Version, WithdrawalBundle, - WithdrawalBundleStatus, proto::mainchain::TwoWayPegData, + Authorization, Authorized, AuthorizedTransaction, BlockHash, + BlockIndexEvents, Body, FilledTransaction, GetAddress, GetValue, + Header, InPoint, M6id, MerkleRoot, OutPoint, OutPointKey, Output, + PointedOutput, PointedOutputRef, SpentOutput, Transaction, + UtreexoNodeHash, UtreexoProof, VERSION, Verify, Version, + WithdrawalBundle, WithdrawalBundleStatus, + proto::mainchain::TwoWayPegData, }, util::Watchable, }; @@ -82,6 +83,10 @@ pub struct State { SerdeBincode, SerdeBincode<(WithdrawalBundleInfo, RollBack)>, >, + /// Coin movements that no block body carries, keyed by the height that + /// applied them + pub block_index_events: + DatabaseUnique, SerdeBincode>, /// deposit blocks and the height at which they were applied, keyed sequentially pub deposit_blocks: DatabaseUnique< SerdeBincode, @@ -97,7 +102,7 @@ pub struct State { } impl State { - pub const NUM_DBS: u32 = 11; + pub const NUM_DBS: u32 = 12; pub fn new(env: &sneed::Env) -> Result { let mut rwtxn = env.write_txn().map_err(EnvError::from)?; @@ -124,6 +129,9 @@ impl State { let withdrawal_bundles = DatabaseUnique::create(env, &mut rwtxn, "withdrawal_bundles") .map_err(EnvError::from)?; + let block_index_events = + DatabaseUnique::create(env, &mut rwtxn, "block_index_events") + .map_err(EnvError::from)?; let deposit_blocks = DatabaseUnique::create(env, &mut rwtxn, "deposit_blocks") .map_err(EnvError::from)?; @@ -156,6 +164,7 @@ impl State { pending_withdrawal_bundle, latest_failed_withdrawal_bundle, withdrawal_bundles, + block_index_events, deposit_blocks, withdrawal_bundle_event_blocks, utreexo_accumulator, @@ -163,6 +172,19 @@ impl State { }) } + /// Coin movements that the block at this height applied outside its body. + pub fn get_block_index_events( + &self, + rotxn: &RoTxn, + height: u32, + ) -> Result { + let events = self + .block_index_events + .try_get(rotxn, &height)? + .unwrap_or_default(); + Ok(events) + } + pub fn try_get_tip( &self, rotxn: &RoTxn, @@ -624,8 +646,9 @@ mod test { use crate::{ state::State, types::{ - Address, FilledTransaction, InPoint, OutPoint, OutPointKey, Output, - OutputContent, PointedOutputRef, SpentOutput, Transaction, hash, + Address, BlockIndexEvents, FilledTransaction, InPoint, M6id, + OutPoint, OutPointKey, Output, OutputContent, PointedOutputRef, + SpentOutput, Transaction, hash, }, }; @@ -784,4 +807,57 @@ mod test { ); Ok(()) } + + #[test] + fn block_index_events_round_trip() -> anyhow::Result<()> { + let (_temp_dir, env, state) = fresh_state("block-index-events")?; + let deposit_outpoint = |byte: u8| { + OutPoint::Deposit(bitcoin::OutPoint { + txid: bitcoin::Txid::from_byte_array([byte; 32]), + vout: 0, + }) + }; + let events = BlockIndexEvents { + deposits: vec![( + deposit_outpoint(1), + value_output(Address::ALL_ZEROS, 5000), + )], + bundle_spends: vec![( + deposit_outpoint(2), + M6id(bitcoin::Txid::from_byte_array([3; 32])), + )], + }; + { + let mut rwtxn = env.write_txn()?; + state.block_index_events.put(&mut rwtxn, &7, &events)?; + rwtxn.commit()?; + } + { + let rotxn = env.read_txn()?; + anyhow::ensure!(state.get_block_index_events(&rotxn, 7)? == events); + // A height that moved nothing outside its body reads as empty. + anyhow::ensure!( + state.get_block_index_events(&rotxn, 8)?.is_empty() + ); + } + + // A disconnect drops the events, so a reorg leaves nothing behind for + // the block that takes the height. + { + let mut rwtxn = env.write_txn()?; + state.block_index_events.delete(&mut rwtxn, &7)?; + rwtxn.commit()?; + } + let rotxn = env.read_txn()?; + anyhow::ensure!(state.get_block_index_events(&rotxn, 7)?.is_empty()); + + // A height that moved nothing writes no row, so deleting it again is + // still safe. + { + let mut rwtxn = env.write_txn()?; + state.block_index_events.delete(&mut rwtxn, &8)?; + rwtxn.commit()?; + } + Ok(()) + } } diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index 5b57e540..54ac2a89 100644 --- a/lib/state/two_way_peg_data.rs +++ b/lib/state/two_way_peg_data.rs @@ -11,10 +11,11 @@ use crate::{ error, rollback::RollBack, }, types::{ - AccumulatorDiff, AggregatedWithdrawal, AmountOverflowError, InPoint, - M6id, OutPoint, OutPointKey, Output, OutputContent, PointedOutput, - PointedOutputRef, SpentOutput, WithdrawalBundle, WithdrawalBundleEvent, - WithdrawalBundleEventStatus, WithdrawalBundleStatus, hash, + AccumulatorDiff, AggregatedWithdrawal, AmountOverflowError, + BlockIndexEvents, InPoint, M6id, OutPoint, OutPointKey, Output, + OutputContent, PointedOutput, PointedOutputRef, SpentOutput, + WithdrawalBundle, WithdrawalBundleEvent, WithdrawalBundleEventStatus, + WithdrawalBundleStatus, hash, proto::mainchain::{BlockEvent, TwoWayPegData}, }, }; @@ -113,11 +114,13 @@ fn collect_withdrawal_bundle( Ok(Some(bundle)) } +#[allow(clippy::too_many_arguments)] fn connect_withdrawal_bundle_submitted( state: &State, rwtxn: &mut RwTxn, block_height: u32, accumulator_diff: &mut AccumulatorDiff, + index_events: &mut BlockIndexEvents, event_block_hash: &bitcoin::BlockHash, m6id: M6id, ) -> Result<(), error::ConnectWithdrawalBundleSubmitted> { @@ -160,6 +163,7 @@ fn connect_withdrawal_bundle_submitted( inpoint: InPoint::Withdrawal { m6id }, }; state.stxos.put(rwtxn, &key, &spent_output)?; + index_events.bundle_spends.push((*outpoint, m6id)); } assert_eq!( bundle_status.latest().value, @@ -477,11 +481,13 @@ fn connect_withdrawal_bundle_failed( Ok(()) } +#[allow(clippy::too_many_arguments)] fn connect_withdrawal_bundle_event( state: &State, rwtxn: &mut RwTxn, block_height: u32, accumulator_diff: &mut AccumulatorDiff, + index_events: &mut BlockIndexEvents, event_block_hash: &bitcoin::BlockHash, event: &WithdrawalBundleEvent, ) -> Result<(), Error> { @@ -492,6 +498,7 @@ fn connect_withdrawal_bundle_event( rwtxn, block_height, accumulator_diff, + index_events, event_block_hash, event.m6id, ) @@ -525,6 +532,7 @@ fn connect_event( rwtxn: &mut RwTxn, block_height: u32, accumulator_diff: &mut AccumulatorDiff, + index_events: &mut BlockIndexEvents, latest_deposit_block_hash: &mut Option, latest_withdrawal_bundle_event_block_hash: &mut Option, event_block_hash: bitcoin::BlockHash, @@ -540,6 +548,7 @@ fn connect_event( .map_err(DbError::from)?; let utxo_hash = hash(&PointedOutputRef { outpoint, output }); accumulator_diff.insert(utxo_hash.into()); + index_events.deposits.push((outpoint, output.clone())); *latest_deposit_block_hash = Some(event_block_hash); } BlockEvent::WithdrawalBundle(withdrawal_bundle_event) => { @@ -548,6 +557,7 @@ fn connect_event( rwtxn, block_height, accumulator_diff, + index_events, &event_block_hash, withdrawal_bundle_event, )?; @@ -570,6 +580,7 @@ pub fn connect( .map_err(DbError::from)? .unwrap_or_default(); let mut accumulator_diff = AccumulatorDiff::default(); + let mut index_events = BlockIndexEvents::default(); let mut latest_deposit_block_hash = None; let mut latest_withdrawal_bundle_event_block_hash = None; for (event_block_hash, event_block_info) in &two_way_peg_data.block_info { @@ -579,6 +590,7 @@ pub fn connect( rwtxn, block_height, &mut accumulator_diff, + &mut index_events, &mut latest_deposit_block_hash, &mut latest_withdrawal_bundle_event_block_hash, *event_block_hash, @@ -586,6 +598,14 @@ pub fn connect( )?; } } + // Record what this block moved outside its body. An address index cannot + // see a deposit or a bundle spend any other way. + if !index_events.is_empty() { + state + .block_index_events + .put(rwtxn, &block_height, &index_events) + .map_err(DbError::from)?; + } // Handle deposits. if let Some(latest_deposit_block_hash) = latest_deposit_block_hash { let deposit_block_seq_idx = state @@ -1023,6 +1043,10 @@ pub fn disconnect( let mut accumulator_diff = AccumulatorDiff::default(); let mut latest_deposit_block_hash = None; let mut latest_withdrawal_bundle_event_block_hash = None; + state + .block_index_events + .delete(rwtxn, &block_height) + .map_err(DbError::from)?; // Restore pending withdrawal bundle for (event_block_hash, event_block_info) in two_way_peg_data.block_info.iter().rev() diff --git a/rpc-api/lib.rs b/rpc-api/lib.rs index 054a52be..5b8fe589 100644 --- a/rpc-api/lib.rs +++ b/rpc-api/lib.rs @@ -25,9 +25,9 @@ pub mod node { use l2l_openapi::open_api; use serde::{Deserialize, Serialize}; use thunder_types::{ - Address, Authorized, Block, BlockHash, MerkleRoot, OutPoint, Output, - OutputContent, Pointed, PointedOutput, SpentOutput, Transaction, Txid, - WithdrawalBundle, + Address, Authorized, Block, BlockHash, M6id, MerkleRoot, OutPoint, + Output, OutputContent, Pointed, PointedOutput, SpentOutput, + Transaction, Txid, WithdrawalBundle, net::{Peer, PeerAddress}, schema as thunder_schema, }; @@ -70,6 +70,27 @@ pub mod node { async fn stop(&self); } + /// One transaction of a block, with the fields its body omits + #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] + pub struct BlockIndexTx { + pub txid: Txid, + /// Canonical size in bytes + pub size: u64, + /// Borsh encoding, as hex + pub raw: String, + } + + /// Everything about a block that its body does not carry + #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] + pub struct GetBlockIndexResponse { + /// Transactions in body order + pub txs: Vec, + /// Outputs that mainchain deposits created + pub deposits: Vec<(OutPoint, Output)>, + /// Outputs that a withdrawal bundle removed + pub bundle_spends: Vec<(OutPoint, M6id)>, + } + #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct GetTransactionResponse { pub tx: Transaction, @@ -105,6 +126,26 @@ pub mod node { block_hash: thunder_types::BlockHash, ) -> RpcResult>; + /// Get the block hash at the specified height in the current chain, + /// if it exists + #[open_api_method(output_schema( + PartialSchema = "schema::Optional" + ))] + #[method(name = "get_block_hash")] + async fn get_block_hash( + &self, + height: u32, + ) -> RpcResult>; + + /// Get the transaction ids, sizes and encodings of a block, with the + /// mainchain deposits and withdrawal bundle spends it applied + #[open_api_method(output_schema(ToSchema))] + #[method(name = "get_block_index")] + async fn get_block_index( + &self, + block_hash: thunder_types::BlockHash, + ) -> RpcResult; + /// Get mainchain blocks that commit to a specified block hash #[open_api_method(output_schema( PartialSchema = "thunder_schema::BitcoinBlockHash" diff --git a/types/lib.rs b/types/lib.rs index 37535fbc..ec734938 100644 --- a/types/lib.rs +++ b/types/lib.rs @@ -120,6 +120,25 @@ enum WithdrawalBundleErrorInner { #[error("Withdrawal bundle error")] pub struct WithdrawalBundleError(#[from] WithdrawalBundleErrorInner); +/// Coin movements that a block body does not carry: a mainchain deposit, and +/// the outputs a withdrawal bundle removed +#[derive( + Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, ToSchema, +)] +pub struct BlockIndexEvents { + /// Outputs that mainchain deposits created + pub deposits: Vec<(transaction::OutPoint, transaction::Output)>, + /// Outputs that a withdrawal bundle removed, with the bundle that took them + pub bundle_spends: Vec<(transaction::OutPoint, M6id)>, +} + +impl BlockIndexEvents { + /// True when the block moved no coins outside its body + pub fn is_empty(&self) -> bool { + self.deposits.is_empty() && self.bundle_spends.is_empty() + } +} + #[serde_as] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)] pub struct WithdrawalBundle { diff --git a/types/transaction.rs b/types/transaction.rs index 7af8380c..ffbd6307 100644 --- a/types/transaction.rs +++ b/types/transaction.rs @@ -609,6 +609,11 @@ impl Transaction { pub fn canonical_size(&self) -> u64 { (borsh::object_length(self).unwrap() / 8) as u64 } + + /// Canonical encoding. This is the form the txid hashes over. + pub fn canonical_encoding(&self) -> Vec { + borsh::to_vec(self).expect("serializing a transaction cannot fail") + } } /// Representation of a spent output