From 73c52c9e5ba74babde67950cf4d9cd561f11700c Mon Sep 17 00:00:00 2001 From: ekulkisnek Date: Mon, 21 Sep 2026 10:43:04 -0500 Subject: [PATCH] fix: align Betanet coinbase encoding and commitments with seed Port coinbase commitments from octobocto/truthcoin-dc be914276 and database guards from e9eaa8ff. Preserve PR22 network and reorg tests; add a captured Betanet genesis response regression. --- Cargo.lock | 20 +- Cargo.toml | 3 +- app/app.rs | 6 +- app/gui/activity/block_explorer.rs | 1 + app/gui/activity/mempool_explorer.rs | 4 +- app/gui/coins/utxo_selector.rs | 8 +- lib/Cargo.toml | 1 + lib/archive/mod.rs | 4 +- lib/mempool.rs | 29 ++- .../fixtures/betanet-genesis-response.bin | Bin 0 -> 157 bytes lib/net/peer/message.rs | 31 +++ lib/net/peer/request_queue.rs | 12 +- lib/node/net_task.rs | 2 +- lib/state/block.rs | 21 +- lib/state/error.rs | 10 +- lib/state/mod.rs | 18 +- lib/state/two_way_peg_data.rs | 2 +- lib/types/hashes.rs | 78 ++++++ lib/types/mod.rs | 234 ++++++++++++++++-- lib/types/transaction/mod.rs | 12 +- lib/types/transaction/output.rs | 8 +- lib/validation/block.rs | 19 +- lib/wallet.rs | 26 +- rpc-api/lib.rs | 11 +- rpc-api/rpc.rs | 55 ++-- 25 files changed, 503 insertions(+), 112 deletions(-) create mode 100644 lib/net/peer/fixtures/betanet-genesis-response.bin diff --git a/Cargo.lock b/Cargo.lock index 2c457cdc..2111c8e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4392,6 +4392,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merkle-cbt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171d2f700835121c3b04ccf0880882987a050fd5c7ae88148abf537d33dd3a56" +dependencies = [ + "cfg-if", +] + [[package]] name = "metal" version = "0.31.0" @@ -7683,7 +7692,7 @@ dependencies = [ [[package]] name = "truthcoin_dc" -version = "0.17.0" +version = "0.18.0" dependencies = [ "addr", "anyhow", @@ -7717,6 +7726,7 @@ dependencies = [ "jsonrpsee", "libes", "libm", + "merkle-cbt", "nalgebra", "ndarray", "nonempty 0.11.0", @@ -7759,7 +7769,7 @@ dependencies = [ [[package]] name = "truthcoin_dc_app" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -7810,7 +7820,7 @@ dependencies = [ [[package]] name = "truthcoin_dc_app_cli" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "bitcoin", @@ -7834,7 +7844,7 @@ dependencies = [ [[package]] name = "truthcoin_dc_app_rpc_api" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "bitcoin", @@ -7850,7 +7860,7 @@ dependencies = [ [[package]] name = "truthcoin_dc_integration_tests" -version = "0.17.0" +version = "0.18.0" dependencies = [ "anyhow", "bip300301_enforcer_integration_tests", diff --git a/Cargo.toml b/Cargo.toml index 54e96f34..0a8cc717 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ authors = ["Ash Manning ", edition = "2024" license-file = "LICENSE.txt" publish = false -version = "0.17.0" +version = "0.18.0" [workspace.dependencies] anyhow = "1.0.72" @@ -28,6 +28,7 @@ hickory-resolver = { version = "0.26", default-features = false } http = "1.2.0" itertools = "0.14.0" jsonrpsee = { version = "0.26.0", features = ["tracing"] } +merkle-cbt = "0.3.2" parking_lot = "0.12.1" prost = "0.14.3" # needs to line up with version required by frost-core diff --git a/app/app.rs b/app/app.rs index 7e63a561..bd299601 100644 --- a/app/app.rs +++ b/app/app.rs @@ -523,6 +523,10 @@ impl App { block connection" ); } + let coinbase = types::Coinbase { + memo: Vec::new(), + outputs: coinbase, + }; let merkle_root = Body::compute_merkle_root( &coinbase, &txs.iter() @@ -547,7 +551,7 @@ impl App { }); (bribe, header, body, tx_fees) } else { - let coinbase = Vec::new(); + let coinbase = types::Coinbase::default(); let merkle_root = Body::compute_merkle_root(&coinbase, &[]); let body = Body::new(Vec::new(), coinbase); let header = types::Header { diff --git a/app/gui/activity/block_explorer.rs b/app/gui/activity/block_explorer.rs index 1a7830dc..dec71f94 100644 --- a/app/gui/activity/block_explorer.rs +++ b/app/gui/activity/block_explorer.rs @@ -53,6 +53,7 @@ impl BlockExplorer { bincode::serialize(&body).unwrap_or(vec![]).len(); let coinbase_value: bitcoin::Amount = body .coinbase + .outputs .iter() .map(GetBitcoinValue::get_bitcoin_value) .sum(); diff --git a/app/gui/activity/mempool_explorer.rs b/app/gui/activity/mempool_explorer.rs index d46fbdb5..9902f6dc 100644 --- a/app/gui/activity/mempool_explorer.rs +++ b/app/gui/activity/mempool_explorer.rs @@ -116,9 +116,9 @@ impl MempoolExplorer { format!("{}", outpoint.txid), outpoint.vout, ), - OutPoint::Coinbase { merkle_root, vout } => ( + OutPoint::Coinbase { txid, vout } => ( "coinbase", - format!("{merkle_root}"), + format!("{txid}"), *vout, ), OutPoint::MarketFunds { diff --git a/app/gui/coins/utxo_selector.rs b/app/gui/coins/utxo_selector.rs index a83708fa..1f8c5e93 100644 --- a/app/gui/coins/utxo_selector.rs +++ b/app/gui/coins/utxo_selector.rs @@ -196,8 +196,8 @@ pub fn show_utxo( OutPoint::Deposit(outpoint) => { ("deposit", format!("{}", outpoint.txid), outpoint.vout) } - OutPoint::Coinbase { merkle_root, vout } => { - ("coinbase", format!("{merkle_root}"), *vout) + OutPoint::Coinbase { txid, vout } => { + ("coinbase", format!("{txid}"), *vout) } OutPoint::MarketFunds { market_id, @@ -250,8 +250,8 @@ pub fn show_unconfirmed_utxo( OutPoint::Deposit(outpoint) => { ("deposit", format!("{}", outpoint.txid), outpoint.vout) } - OutPoint::Coinbase { merkle_root, vout } => { - ("coinbase", format!("{merkle_root}"), *vout) + OutPoint::Coinbase { txid, vout } => { + ("coinbase", format!("{txid}"), *vout) } OutPoint::MarketFunds { market_id, diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 98f8486b..07b040a8 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -40,6 +40,7 @@ itertools = { workspace = true } jsonrpsee = { workspace = true } libes = { workspace = true } libm = "0.2" +merkle-cbt = { workspace = true } nalgebra = "0.33" ndarray = "0.15.6" nonempty = { version = "0.11.0", features = ["serialize"] } diff --git a/lib/archive/mod.rs b/lib/archive/mod.rs index c990f243..ecd56aa0 100644 --- a/lib/archive/mod.rs +++ b/lib/archive/mod.rs @@ -115,8 +115,8 @@ impl Archive { if db_version < Version { major: 0, - minor: 15, - patch: 1, + minor: 18, + patch: 0, } => { return Err(Error::IncompatibleVersion { diff --git a/lib/mempool.rs b/lib/mempool.rs index 9e4e74ab..bcea482d 100644 --- a/lib/mempool.rs +++ b/lib/mempool.rs @@ -1,4 +1,7 @@ -use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; +use std::{ + collections::{BTreeSet, HashMap, HashSet, VecDeque}, + path::PathBuf, +}; use fallible_iterator::FallibleIterator as _; use futures::{Stream, StreamExt}; @@ -26,6 +29,12 @@ use crate::{ #[transitive(from(env::error::WriteTxn, EnvError))] #[transitive(from(rwtxn::error::Commit, RwTxnError))] pub enum Error { + #[error( + "Incompatible DB version ({}). Please clear the DB (`{}`) and re-sync", + .version, + .db_path.display() + )] + IncompatibleVersion { version: Version, db_path: PathBuf }, #[error(transparent)] Db(#[from] DbError), #[error("Database env error")] @@ -81,8 +90,22 @@ impl MemPool { DatabaseUnique::create(env, &mut rwtxn, "trade_order_counter")?; let version = DatabaseUnique::create(env, &mut rwtxn, "mempool_version")?; - if version.try_get(&rwtxn, &())?.is_none() { - version.put(&mut rwtxn, &(), &*VERSION)?; + match version.try_get(&rwtxn, &())? { + Some(db_version) + if db_version + < Version { + major: 0, + minor: 18, + patch: 0, + } => + { + return Err(Error::IncompatibleVersion { + version: db_version, + db_path: env.path().to_path_buf(), + }); + } + Some(_) => (), + None => version.put(&mut rwtxn, &(), &*VERSION)?, } rwtxn.commit()?; Ok(Self { diff --git a/lib/net/peer/fixtures/betanet-genesis-response.bin b/lib/net/peer/fixtures/betanet-genesis-response.bin new file mode 100644 index 0000000000000000000000000000000000000000..ef05219dd35532758bdb7511ef4973b76c5ebf8a GIT binary patch literal 157 zcmZQzU|JI?z(p}pwlVm_5H1_huZ5V&wM_+j_Y z(w)5%i$40?O|EGa{-<5e^^u1eA`K@Qp)CFzJMZsSQ%kwPRHDm&u+;kr@8#ogMMN+F DU?(NN literal 0 HcmV?d00001 diff --git a/lib/net/peer/message.rs b/lib/net/peer/message.rs index f3fb2e12..5b6f3a29 100644 --- a/lib/net/peer/message.rs +++ b/lib/net/peer/message.rs @@ -340,3 +340,34 @@ mod network_tests { assert_eq!(magics.len(), EXPECTED.len()); } } + +#[cfg(test)] +mod betanet_capture_tests { + use super::ResponseMessage; + use crate::types::Body; + + #[test] + fn betanet_seed_genesis_response_matches_commitments() { + let bytes = include_bytes!("fixtures/betanet-genesis-response.bin"); + let response: ResponseMessage = bincode::deserialize(bytes).unwrap(); + assert_eq!(bincode::serialize(&response).unwrap(), bytes); + let ResponseMessage::Block { header, mut body } = response else { + panic!("expected block response"); + }; + assert_eq!( + header.hash().to_string(), + "bcac497706c7552b0bd6791212318ccea83b1d48d1164f54079dc482dc517bfb" + ); + assert_eq!( + Body::compute_merkle_root(&body.coinbase, &body.transactions), + header.merkle_root + ); + assert!(body.coinbase.memo.is_empty()); + assert_eq!(body.coinbase.outputs.len(), 1); + body.coinbase.memo.push(1); + assert_ne!( + Body::compute_merkle_root(&body.coinbase, &body.transactions), + header.merkle_root + ); + } +} diff --git a/lib/net/peer/request_queue.rs b/lib/net/peer/request_queue.rs index 78eaea34..a8e1f35d 100644 --- a/lib/net/peer/request_queue.rs +++ b/lib/net/peer/request_queue.rs @@ -48,8 +48,8 @@ impl ErrorRx { enum SourceItem { Error(error::channel_pool::SendMessage), Heartbeat(Heartbeat, channel_pool::LimiterGuard), - PeerResponse(PeerResponseItem), - Request(Request, channel_pool::LimiterGuard), + PeerResponse(Box), + Request(Box, channel_pool::LimiterGuard), } let (channel_pool, channel_pool_rx) = ChannelPool::new(connection); let channel_pool_stream = channel_pool_rx @@ -59,7 +59,7 @@ impl ErrorRx { SourceItem::Error(error) } futures::future::Either::Right(peer_response) => { - SourceItem::PeerResponse(peer_response) + SourceItem::PeerResponse(Box::new(peer_response)) } }) .boxed(); @@ -90,7 +90,7 @@ impl ErrorRx { .until_n_ready(request_cost(&request)) .await .unwrap(); - SourceItem::Request(request, guard) + SourceItem::Request(Box::new(request), guard) } } }) @@ -111,13 +111,13 @@ impl ErrorRx { } } SourceItem::Request(request, guard) => { - match channel_pool.send_request(request, guard) { + match channel_pool.send_request(*request, guard) { Ok(()) => None, Err(err) => Some(err.into()), } } SourceItem::PeerResponse(peer_response) => { - match peer_response_tx.unbounded_send(peer_response) { + match peer_response_tx.unbounded_send(*peer_response) { Ok(()) => None, Err(_err) => { Some(error::request_queue::Error::PushPeerResponse) diff --git a/lib/node/net_task.rs b/lib/node/net_task.rs index ccd3ca67..2652644f 100644 --- a/lib/node/net_task.rs +++ b/lib/node/net_task.rs @@ -1840,7 +1840,7 @@ mod test { let main_hash = bitcoin::BlockHash::from_byte_array([1; 32]); let body = Body { - coinbase: Vec::new(), + coinbase: crate::types::Coinbase::default(), transactions: Vec::new(), authorizations: Vec::new(), actor_proofs: Vec::new(), diff --git a/lib/state/block.rs b/lib/state/block.rs index 2d1e4621..c204db73 100644 --- a/lib/state/block.rs +++ b/lib/state/block.rs @@ -734,7 +734,7 @@ pub fn connect_prevalidated( state .genesis_timestamp .put(rwtxn, &(), &mainchain_timestamp)?; - if let Some(first_coinbase) = body.coinbase.first() { + if let Some(first_coinbase) = body.coinbase.outputs.first() { state.reputation().set_reputation( rwtxn, &first_coinbase.address, @@ -780,13 +780,13 @@ pub fn connect_prevalidated( } crate::validation::BlockValidator::validate_coinbase_outputs( - &body.coinbase, + &body.coinbase.outputs, height, )?; - for (vout, output) in body.coinbase.iter().enumerate() { + for (vout, output) in body.coinbase.outputs.iter().enumerate() { let outpoint = OutPoint::Coinbase { - merkle_root: header.merkle_root, + txid: header.compute_coinbase_txid(), vout: vout as u32, }; let filled_content = match output.content.clone() { @@ -1209,10 +1209,14 @@ pub fn disconnect_tip( } // 6. Revert coinbase UTXOs - body.coinbase.iter().enumerate().rev().try_for_each( - |(vout, _output)| { + body.coinbase + .outputs + .iter() + .enumerate() + .rev() + .try_for_each(|(vout, _output)| { let outpoint = OutPoint::Coinbase { - merkle_root: header.merkle_root, + txid: header.compute_coinbase_txid(), vout: vout as u32, }; if state.delete_utxo(rwtxn, &outpoint)? { @@ -1220,8 +1224,7 @@ pub fn disconnect_tip( } else { Err(Error::NoUtxo { outpoint }) } - }, - )?; + })?; // 7. Rollback decision states (Claimed → Voting transitions) if height > 0 { diff --git a/lib/state/error.rs b/lib/state/error.rs index 98953f24..bbb3abd9 100644 --- a/lib/state/error.rs +++ b/lib/state/error.rs @@ -1,13 +1,15 @@ //! State errors #![allow(clippy::duplicated_attributes)] +use std::path::PathBuf; + use sneed::{db::error as db, env::error as env, rwtxn::error as rwtxn}; use thiserror::Error; use transitive::Transitive; use crate::types::{ AmountOverflowError, AmountUnderflowError, BlockHash, M6id, MerkleRoot, - OutPoint, WithdrawalBundleError, + OutPoint, Version, WithdrawalBundleError, }; #[derive(Debug, Error)] @@ -63,6 +65,12 @@ impl std::error::Error for FillTxOutputContents {} #[transitive(from(rwtxn::Commit, rwtxn::Error))] #[transitive(from(rwtxn::Error, sneed::Error))] pub enum Error { + #[error( + "Incompatible DB version ({}). Please clear the DB (`{}`) and re-sync", + .version, + .db_path.display() + )] + IncompatibleVersion { version: Version, db_path: PathBuf }, #[error(transparent)] Market(#[from] crate::state::markets::MarketError), #[error(transparent)] diff --git a/lib/state/mod.rs b/lib/state/mod.rs index f43e2b2d..ffbe6fea 100644 --- a/lib/state/mod.rs +++ b/lib/state/mod.rs @@ -285,8 +285,22 @@ impl State { "withdrawal_bundle_event_blocks", )?; let version = DatabaseUnique::create(env, &mut rwtxn, "state_version")?; - if version.try_get(&rwtxn, &())?.is_none() { - version.put(&mut rwtxn, &(), &*VERSION)?; + match version.try_get(&rwtxn, &())? { + Some(db_version) + if db_version + < Version { + major: 0, + minor: 18, + patch: 0, + } => + { + return Err(Error::IncompatibleVersion { + version: db_version, + db_path: env.path().to_path_buf(), + }); + } + Some(_) => (), + None => version.put(&mut rwtxn, &(), &*VERSION)?, } let settlement_undo = DatabaseUnique::create(env, &mut rwtxn, "settlement_undo")?; diff --git a/lib/state/two_way_peg_data.rs b/lib/state/two_way_peg_data.rs index 29607429..a716c571 100644 --- a/lib/state/two_way_peg_data.rs +++ b/lib/state/two_way_peg_data.rs @@ -1377,7 +1377,7 @@ mod tests { BatchVerificationContext::new(&mut rand::rng()); let empty_body = Body { - coinbase: Vec::new(), + coinbase: crate::types::Coinbase::default(), transactions: Vec::new(), authorizations: Vec::new(), actor_proofs: Vec::new(), diff --git a/lib/types/hashes.rs b/lib/types/hashes.rs index 802a68bc..cceeb10c 100644 --- a/lib/types/hashes.rs +++ b/lib/types/hashes.rs @@ -11,6 +11,84 @@ use super::serde_hexstr_human_readable; pub type Hash = [u8; blake3::OUT_LEN]; +/// Declare a transparent wrapper around [`Hash`], with the conversions and +/// the hex formatting that every such wrapper needs. +macro_rules! new_hash_wrapper { + ($vis:vis $ident:ident) => { + #[derive( + BorshSerialize, + BorshDeserialize, + Clone, + Copy, + Default, + Deserialize, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + #[repr(transparent)] + #[serde(transparent)] + $vis struct $ident( + #[serde(with = "serde_hexstr_human_readable")] pub Hash, + ); + + impl From for $ident { + fn from(other: Hash) -> Self { + Self(other) + } + } + + impl From<$ident> for Hash { + fn from(other: $ident) -> Self { + other.0 + } + } + + impl FromStr for $ident { + type Err = const_hex::FromHexError; + + fn from_str(s: &str) -> Result { + Hash::from_hex(s).map(Self) + } + } + + impl std::fmt::Debug for $ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", const_hex::encode(self.0)) + } + } + + impl std::fmt::Display for $ident { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", const_hex::encode(self.0)) + } + } + + impl utoipa::PartialSchema for $ident { + fn schema() + -> utoipa::openapi::RefOr { + let obj = utoipa::openapi::Object::with_type( + utoipa::openapi::Type::String, + ); + utoipa::openapi::RefOr::T(utoipa::openapi::Schema::Object(obj)) + } + } + + impl utoipa::ToSchema for $ident { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(stringify!($ident)) + } + } + }; +} + +new_hash_wrapper!(pub CoinbaseMerkleRoot); +new_hash_wrapper!(pub CoinbaseTxid); +new_hash_wrapper!(pub OutputsMerkleRoot); + #[derive( BorshSerialize, BorshDeserialize, diff --git a/lib/types/mod.rs b/lib/types/mod.rs index 0ef280dd..c50f7db6 100644 --- a/lib/types/mod.rs +++ b/lib/types/mod.rs @@ -23,8 +23,12 @@ mod transaction; pub mod tx_pow; pub use address::Address; -pub use hashes::{AssetId, BlockHash, Hash, M6id, MerkleRoot, Txid}; +pub use hashes::{ + AssetId, BlockHash, CoinbaseMerkleRoot, CoinbaseTxid, Hash, M6id, + MerkleRoot, OutputsMerkleRoot, Txid, +}; pub use keys::{EncryptionPubKey, VerifyingKey}; +pub(crate) use transaction::output::borsh_serialize_bitcoin_amount; pub use transaction::{ AssetOutput, AssetOutputContent, Authorized, AuthorizedTransaction, BallotItem, BitcoinOutput, BitcoinOutputContent, ClaimDecisionPayload, @@ -169,6 +173,19 @@ pub struct Header { } impl Header { + pub fn compute_coinbase_txid(&self) -> CoinbaseTxid { + let Self { + merkle_root, + prev_side_hash, + prev_main_hash, + } = self; + Coinbase::compute_txid( + merkle_root, + prev_main_hash, + prev_side_hash.as_ref(), + ) + } + pub fn hash(&self) -> BlockHash { hashes::hash_with_scratch_buffer(self).into() } @@ -487,9 +504,155 @@ pub struct TwoWayPegData { pub bundle_statuses: HashMap, } +/// Hash to get a coinbase CBMT node commitment for a leaf value +#[derive(BorshSerialize, Debug)] +struct CoinbaseCbmtLeafPreCommitment<'a> { + #[borsh(serialize_with = "borsh_serialize_bitcoin_amount")] + value: bitcoin::Amount, + canonical_size: u64, + output: &'a Output, +} + +/// Hash to get a coinbase CBMT node commitment for an internal node +#[derive(BorshSerialize, Debug)] +struct CoinbaseCbmtNodePreCommitment { + left_commitment: Hash, + #[borsh(serialize_with = "borsh_serialize_bitcoin_amount")] + value: bitcoin::Amount, + canonical_size: u64, + right_commitment: Hash, +} + +/// Internal node of the coinbase CBMT +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct CoinbaseCbmtNode { + commitment: Hash, + value: bitcoin::Amount, + canonical_size: u64, + /// CBT index. `CoinbaseCbmtNode` orders by this, and nothing else. + index: usize, +} + +impl PartialOrd for CoinbaseCbmtNode { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CoinbaseCbmtNode { + fn cmp(&self, other: &Self) -> Ordering { + self.index.cmp(&other.index) + } +} + +/// Marker type for merging coinbase branch commitments with value and +/// canonical size totals. +struct MergeValueSizeTotal; + +impl merkle_cbt::merkle_tree::Merge for MergeValueSizeTotal { + type Item = CoinbaseCbmtNode; + + fn merge(lnode: &Self::Item, rnode: &Self::Item) -> Self::Item { + assert_eq!(lnode.index + 1, rnode.index); + let index = (lnode.index - 1) / 2; + let value = lnode.value + rnode.value; + let canonical_size = lnode.canonical_size + rnode.canonical_size; + let commitment = + hashes::hash_with_scratch_buffer(&CoinbaseCbmtNodePreCommitment { + left_commitment: lnode.commitment, + value, + canonical_size, + right_commitment: rnode.commitment, + }); + CoinbaseCbmtNode { + commitment, + value, + canonical_size, + index, + } + } +} + +/// Complete binary merkle tree over coinbase outputs +type CoinbaseCbmt = merkle_cbt::CBMT; + +/// Coinbase transaction of a block +#[derive( + BorshSerialize, Clone, Debug, Default, Deserialize, Serialize, ToSchema, +)] +pub struct Coinbase { + #[serde(with = "serde_hexstr_human_readable")] + #[schema(value_type = String)] + pub memo: Vec, + pub outputs: Vec, +} + +impl Coinbase { + /// Commitment to every output, with the value and the canonical size + /// totalled at each branch. + fn compute_outputs_merkle_root(&self) -> OutputsMerkleRoot { + let n_outputs = self.outputs.len(); + let leaves: Vec = self + .outputs + .iter() + .enumerate() + .map(|(index, output)| { + let value = output.get_bitcoin_value(); + let canonical_size = output.canonical_size(); + let commitment = hashes::hash_with_scratch_buffer( + &CoinbaseCbmtLeafPreCommitment { + value, + canonical_size, + output, + }, + ); + CoinbaseCbmtNode { + commitment, + value, + canonical_size, + index: (index + n_outputs) - 1, + } + }) + .collect(); + let CoinbaseCbmtNode { commitment, .. } = + CoinbaseCbmt::build_merkle_root(leaves.as_slice()); + commitment.into() + } + + /// Commitment to the memo and the outputs + pub fn compute_merkle_root(&self) -> CoinbaseMerkleRoot { + let outputs_commitment = self.compute_outputs_merkle_root(); + hashes::hash_with_scratch_buffer(&(&self.memo, outputs_commitment)) + .into() + } + + /// A coinbase txid hashes the merkle root of its block, the previous + /// mainchain hash, and the previous sidechain hash. + pub fn compute_txid( + merkle_root: &MerkleRoot, + prev_main_hash: &bitcoin::BlockHash, + prev_side_hash: Option<&BlockHash>, + ) -> CoinbaseTxid { + #[derive(BorshSerialize)] + struct HashComponents<'a> { + merkle_root: &'a MerkleRoot, + #[borsh(serialize_with = "borsh_serialize_bitcoin_block_hash")] + prev_main_hash: &'a bitcoin::BlockHash, + prev_side_hash: Option<&'a BlockHash>, + } + + hashes::hash_with_scratch_buffer(&HashComponents { + merkle_root, + prev_main_hash, + prev_side_hash, + }) + .into() + } +} + #[derive(BorshSerialize, Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct Body { - pub coinbase: Vec, + pub coinbase: Coinbase, pub transactions: Vec, pub authorizations: Vec, #[serde(default)] @@ -502,7 +665,7 @@ impl Body { pub fn new( authorized_transactions: Vec, - coinbase: Vec, + coinbase: Coinbase, ) -> Self { let mut authorizations = Vec::with_capacity( authorized_transactions @@ -552,10 +715,10 @@ impl Body { } pub fn compute_merkle_root( - coinbase: &[Output], + coinbase: &Coinbase, txs: &[Transaction], ) -> MerkleRoot { - let coinbase_hash: Hash = hashes::hash_with_scratch_buffer(coinbase); + let coinbase_hash: Hash = coinbase.compute_merkle_root().into(); let mut leaves: Vec = std::iter::once(coinbase_hash) .chain(txs.iter().map(|tx| tx.txid().into())) .collect(); @@ -586,30 +749,11 @@ impl Body { .collect() } - pub fn get_outputs(&self) -> HashMap { - let mut outputs = HashMap::new(); - let merkle_root = - Body::compute_merkle_root(&self.coinbase, &self.transactions); - for (vout, output) in self.coinbase.iter().enumerate() { - let vout = vout as u32; - let outpoint = OutPoint::Coinbase { merkle_root, vout }; - outputs.insert(outpoint, output.clone()); - } - for transaction in &self.transactions { - let txid = transaction.txid(); - for (vout, output) in transaction.outputs.iter().enumerate() { - let vout = vout as u32; - let outpoint = OutPoint::Regular { txid, vout }; - outputs.insert(outpoint, output.clone()); - } - } - outputs - } - pub fn get_coinbase_value( &self, ) -> Result { self.coinbase + .outputs .iter() .map(|output| output.get_bitcoin_value()) .checked_sum() @@ -842,3 +986,43 @@ mod withdrawal_bundle_order_regression { } } } + +#[cfg(test)] +mod coinbase_tests { + use bitcoin::hashes::Hash as _; + + use super::{BlockHash, Coinbase, Header, MerkleRoot}; + + fn header(merkle_root: [u8; 32], main: u8, side: Option) -> Header { + Header { + merkle_root: MerkleRoot::from(merkle_root), + prev_side_hash: side.map(|b| BlockHash::from([b; 32])), + prev_main_hash: bitcoin::BlockHash::from_byte_array([main; 32]), + } + } + + #[test] + fn coinbase_txid_binds_the_block_it_sits_in() { + let base = header([1; 32], 2, Some(3)); + assert_eq!( + base.compute_coinbase_txid(), + Coinbase::compute_txid( + &base.merkle_root, + &base.prev_main_hash, + base.prev_side_hash.as_ref(), + ) + ); + for other in [ + header([9; 32], 2, Some(3)), + header([1; 32], 9, Some(3)), + header([1; 32], 2, Some(9)), + header([1; 32], 2, None), + ] { + assert_ne!( + base.compute_coinbase_txid(), + other.compute_coinbase_txid(), + "{other:?} shares a coinbase txid with {base:?}" + ); + } + } +} diff --git a/lib/types/transaction/mod.rs b/lib/types/transaction/mod.rs index 69094d77..eba09939 100644 --- a/lib/types/transaction/mod.rs +++ b/lib/types/transaction/mod.rs @@ -20,12 +20,12 @@ use crate::{ types::{ AmountOverflowError, GetAddress, GetBitcoinValue, address::Address, - hashes::{self, AssetId, M6id, MerkleRoot, Txid}, + hashes::{self, AssetId, CoinbaseTxid, M6id, MerkleRoot, Txid}, serde_hexstr_human_readable, }, }; -mod output; +pub(crate) mod output; pub use output::{ AssetContent as AssetOutputContent, AssetOutput, BitcoinContent as BitcoinOutputContent, BitcoinOutput, @@ -84,7 +84,7 @@ pub enum OutPoint { }, // Created by block bodies. Coinbase { - merkle_root: MerkleRoot, + txid: CoinbaseTxid, vout: u32, }, // Created by mainchain deposits. @@ -113,8 +113,8 @@ impl std::fmt::Display for OutPoint { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Regular { txid, vout } => write!(f, "regular {txid} {vout}"), - Self::Coinbase { merkle_root, vout } => { - write!(f, "coinbase {merkle_root} {vout}") + Self::Coinbase { txid, vout } => { + write!(f, "coinbase {txid} {vout}") } Self::Deposit(bitcoin::OutPoint { txid, vout }) => { write!(f, "deposit {txid} {vout}") @@ -320,7 +320,7 @@ mod tests { vout: u32::MAX, }, OutPoint::Coinbase { - merkle_root: Default::default(), + txid: Default::default(), vout: u32::MAX, }, OutPoint::Deposit(bitcoin::OutPoint { diff --git a/lib/types/transaction/output.rs b/lib/types/transaction/output.rs index 424217bf..bb4000cd 100644 --- a/lib/types/transaction/output.rs +++ b/lib/types/transaction/output.rs @@ -32,7 +32,7 @@ impl SerializeAs for BitcoinAmountSats { } } -fn borsh_serialize_bitcoin_amount( +pub(crate) fn borsh_serialize_bitcoin_amount( bitcoin_amount: &bitcoin::Amount, writer: &mut W, ) -> borsh::io::Result<()> @@ -791,6 +791,12 @@ impl Output { pub type TxOutput = Output; impl TxOutput { + /// Canonical size in bytes. The canonical encoding is the form that the + /// merkle root commits to. + pub(crate) fn canonical_size(&self) -> u64 { + borsh::object_length(self).unwrap_or(0) as u64 + } + /// `true` if the output content corresponds to a Bitcoin Value pub fn is_bitcoin(&self) -> bool { self.content.is_bitcoin() diff --git a/lib/validation/block.rs b/lib/validation/block.rs index 4b860ccd..d441aba1 100644 --- a/lib/validation/block.rs +++ b/lib/validation/block.rs @@ -54,7 +54,7 @@ impl BlockValidator { state.try_get_height(rotxn)?.map_or(0, |height| height + 1); let mut coinbase_value = bitcoin::Amount::ZERO; - for output in &body.coinbase { + for output in &body.coinbase.outputs { coinbase_value = coinbase_value .checked_add(output.get_bitcoin_value()) .ok_or(AmountOverflowError)?; @@ -441,13 +441,16 @@ mod tests { let rotxn = env.read_txn().unwrap(); let body = Body { - coinbase: vec![Output { - address: Address::ALL_ZEROS, - content: OutputContent::Bitcoin(BitcoinOutputContent( - bitcoin::Amount::ZERO, - )), - memo: vec![0u8; Body::MAX_SIZE + 1], - }], + coinbase: crate::types::Coinbase { + memo: Vec::new(), + outputs: vec![Output { + address: Address::ALL_ZEROS, + content: OutputContent::Bitcoin(BitcoinOutputContent( + bitcoin::Amount::ZERO, + )), + memo: vec![0u8; Body::MAX_SIZE + 1], + }], + }, transactions: Vec::new(), authorizations: Vec::new(), actor_proofs: Vec::new(), diff --git a/lib/wallet.rs b/lib/wallet.rs index 0a05c7e3..06f70131 100644 --- a/lib/wallet.rs +++ b/lib/wallet.rs @@ -1,6 +1,6 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, - path::Path, + path::{Path, PathBuf}, }; use bitcoin::{ @@ -142,6 +142,12 @@ pub struct VkDoesNotExistError { #[transitive(from(env::error::WriteTxn, EnvError))] #[transitive(from(rwtxn::error::Commit, RwTxnError))] pub enum Error { + #[error( + "Incompatible DB version ({}). Please clear the DB (`{}`) and re-sync", + .version, + .db_path.display() + )] + IncompatibleVersion { version: Version, db_path: PathBuf }, #[error("address {address} does not exist")] AddressDoesNotExist { address: crate::types::Address }, #[error(transparent)] @@ -280,8 +286,22 @@ impl Wallet { let vk_to_index = DatabaseUnique::create(&env, &mut rwtxn, "vk_to_index")?; let version = DatabaseUnique::create(&env, &mut rwtxn, "version")?; - if version.try_get(&rwtxn, &())?.is_none() { - version.put(&mut rwtxn, &(), &*VERSION)?; + match version.try_get(&rwtxn, &())? { + Some(db_version) + if db_version + < Version { + major: 0, + minor: 18, + patch: 0, + } => + { + return Err(Error::IncompatibleVersion { + version: db_version, + db_path: env.path().to_path_buf(), + }); + } + Some(_) => (), + None => version.put(&mut rwtxn, &(), &*VERSION)?, } rwtxn.commit()?; Ok(Self { diff --git a/rpc-api/lib.rs b/rpc-api/lib.rs index 6281150f..66da128c 100644 --- a/rpc-api/lib.rs +++ b/rpc-api/lib.rs @@ -12,11 +12,12 @@ use truthcoin_dc::{ types::{ Address, Authorization, Authorized, BitcoinOutputContent, Block, BlockHash, BlockIndex, BlockIndexDeposit, BlockIndexSpend, - BlockIndexTx, Body, ClaimDecisionPayload, DecisionClaimEntry, - EncryptionPubKey, FilledOutput, FilledOutputContent, Header, InPoint, - M6id, MainchainSyncPhase, MainchainSyncProgress, MerkleRoot, OutPoint, - Output, OutputContent, PointedOutput, SpentOutput, Transaction, TxData, - TxIn, Txid, VerifyingKey, WithdrawalBundle, WithdrawalOutputContent, + BlockIndexTx, Body, ClaimDecisionPayload, Coinbase, CoinbaseTxid, + DecisionClaimEntry, EncryptionPubKey, FilledOutput, + FilledOutputContent, Header, InPoint, M6id, MainchainSyncPhase, + MainchainSyncProgress, MerkleRoot, OutPoint, Output, OutputContent, + PointedOutput, SpentOutput, Transaction, TxData, TxIn, Txid, + VerifyingKey, WithdrawalBundle, WithdrawalOutputContent, schema as truthcoin_schema, }, wallet::{Balance, TransferDests}, diff --git a/rpc-api/rpc.rs b/rpc-api/rpc.rs index 2caafb1b..8aa3c973 100644 --- a/rpc-api/rpc.rs +++ b/rpc-api/rpc.rs @@ -20,21 +20,22 @@ pub mod node { Address, Authorization, Authorized, BallotItem, BitcoinOutputContent, Block, BlockHash, BlockIndex, BlockIndexDeposit, BlockIndexSpend, BlockIndexTx, Body, CalculateInitialLiquidityRequest, - ClaimDecisionPayload, ConsensusResults, DecisionClaimEntry, - DecisionContentInfo, DecisionDetails, DecisionFilter, DecisionInfo, - DecisionListItem, DecisionListingFeeInfo, DecisionPeriodStatus, - DecisionState, DecisionSummary, DecisionType, FilledOutput, - FilledOutputContent, Header, InPoint, InitialLiquidityCalculation, - M6id, MainchainSyncPhase, MainchainSyncProgress, MarketData, - MarketDimension, MarketDimensionKind, MarketId, MarketOutcome, - MarketResolution, MarketStatus, MarketSummary, MempoolTx, MerkleRoot, - OutPoint, Output, OutputContent, ParticipationStats, Peer, - PeerConnectionStatus, PeriodPricingSummary, PeriodStats, PointedOutput, - PointedSpentOutput, RpcResult, ScoreChange, SharePosition, Signature, - SocketAddr, SpentOutput, Transaction, TxData, TxIn, TxInfo, Txid, - UserHoldings, VoteFilter, VoteInfo, VoterInfo, VoterInfoFull, - VotingPeriodFull, WinningOutcome, WithdrawalBundle, - WithdrawalOutputContent, rpc, schema, truthcoin_schema, + ClaimDecisionPayload, Coinbase, CoinbaseTxid, ConsensusResults, + DecisionClaimEntry, DecisionContentInfo, DecisionDetails, + DecisionFilter, DecisionInfo, DecisionListItem, DecisionListingFeeInfo, + DecisionPeriodStatus, DecisionState, DecisionSummary, DecisionType, + FilledOutput, FilledOutputContent, Header, InPoint, + InitialLiquidityCalculation, M6id, MainchainSyncPhase, + MainchainSyncProgress, MarketData, MarketDimension, + MarketDimensionKind, MarketId, MarketOutcome, MarketResolution, + MarketStatus, MarketSummary, MempoolTx, MerkleRoot, OutPoint, Output, + OutputContent, ParticipationStats, Peer, PeerConnectionStatus, + PeriodPricingSummary, PeriodStats, PointedOutput, PointedSpentOutput, + RpcResult, ScoreChange, SharePosition, Signature, SocketAddr, + SpentOutput, Transaction, TxData, TxIn, TxInfo, Txid, UserHoldings, + VoteFilter, VoteInfo, VoterInfo, VoterInfoFull, VotingPeriodFull, + WinningOutcome, WithdrawalBundle, WithdrawalOutputContent, rpc, schema, + truthcoin_schema, }; #[open_api] @@ -90,7 +91,8 @@ pub mod node { truthcoin_schema::BitcoinOutPoint, truthcoin_schema::BitcoinTransaction, truthcoin_schema::SocketAddr, Address, Authorization, BallotItem, BitcoinOutputContent, BlockHash, BlockIndexDeposit, BlockIndexSpend, - BlockIndexTx, Body, ClaimDecisionPayload, ConsensusResults, + BlockIndexTx, Body, ClaimDecisionPayload, Coinbase, CoinbaseTxid, + ConsensusResults, DecisionClaimEntry, DecisionContentInfo, DecisionInfo, DecisionState, DecisionSummary, DecisionType, FilledOutput, FilledOutputContent, Header, InPoint, M6id, MainchainSyncPhase, MarketDimension, @@ -390,22 +392,23 @@ pub mod wallet { use crate::{ Address, Authorization, Authorized, Balance, BallotItem, BitcoinOutputContent, Block, BlockHash, Body, ClaimDecisionPayload, - ClaimedDecisionInfo, CreateTradeRequest, CreateTradeResponse, - DecisionClaimEntry, DecisionClaimItem, DecisionClaimRequest, - DecisionClaimResponse, DimensionInput, Dst, EncryptionPubKey, - FilledOutput, FilledOutputContent, GetBlockTemplateResponse, Header, - MarketAmplifyBetaRequest, MarketBuyRequest, MarketBuyResponse, - MarketCreateRequest, MarketCreateResponse, MarketId, MarketSellRequest, - MarketSellResponse, MerkleRoot, OutPoint, Output, OutputContent, - PointedOutput, RpcResult, Signature, Transaction, TransferDests, - TxData, Txid, VerifyingKey, WithdrawalOutputContent, rpc, schema, - truthcoin_schema, + ClaimedDecisionInfo, Coinbase, CoinbaseTxid, CreateTradeRequest, + CreateTradeResponse, DecisionClaimEntry, DecisionClaimItem, + DecisionClaimRequest, DecisionClaimResponse, DimensionInput, Dst, + EncryptionPubKey, FilledOutput, FilledOutputContent, + GetBlockTemplateResponse, Header, MarketAmplifyBetaRequest, + MarketBuyRequest, MarketBuyResponse, MarketCreateRequest, + MarketCreateResponse, MarketId, MarketSellRequest, MarketSellResponse, + MerkleRoot, OutPoint, Output, OutputContent, PointedOutput, RpcResult, + Signature, Transaction, TransferDests, TxData, Txid, VerifyingKey, + WithdrawalOutputContent, rpc, schema, truthcoin_schema, }; #[open_api(ref_schemas[ truthcoin_schema::BitcoinAddr, truthcoin_schema::BitcoinBlockHash, truthcoin_schema::BitcoinOutPoint, Address, Authorization, BallotItem, BitcoinOutputContent, Block, BlockHash, Body, ClaimDecisionPayload, + Coinbase, CoinbaseTxid, ClaimedDecisionInfo, DecisionClaimEntry, DecisionClaimItem, DimensionInput, FilledOutput, Header, MarketId, MerkleRoot, OutPoint, Output, OutputContent, Signature, Transaction, TxData, Txid,