diff --git a/cusf_sidechain_proto b/cusf_sidechain_proto index bc2f38c..5542043 160000 --- a/cusf_sidechain_proto +++ b/cusf_sidechain_proto @@ -1 +1 @@ -Subproject commit bc2f38c1d97e6afe630a1d8de48d9e11aa6e34b5 +Subproject commit 554204360971af96bcda3c3002e51cbb6f76f302 diff --git a/src/messages.rs b/src/messages.rs index 95f2643..26685c0 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -1,8 +1,12 @@ -use bitcoin::opcodes::{ - all::{OP_NOP5, OP_PUSHBYTES_1, OP_RETURN}, - OP_TRUE, -}; +use bitcoin::script::{Instruction, Instructions}; use bitcoin::{hashes::Hash, Amount, Opcode, Script, ScriptBuf, Transaction, TxOut}; +use bitcoin::{ + opcodes::{ + all::{OP_NOP5, OP_PUSHBYTES_1, OP_RETURN}, + OP_TRUE, + }, + script::PushBytesBuf, +}; use byteorder::{ByteOrder, LittleEndian}; use nom::branch::alt; use nom::bytes::complete::{tag, take}; @@ -25,12 +29,15 @@ impl CoinbaseBuilder { CoinbaseBuilder { messages: vec![] } } - pub fn build(self) -> Vec { + pub fn build(self) -> Result, bitcoin::script::PushBytesError> { self.messages .into_iter() - .map(|message| TxOut { - value: Amount::from_sat(0), - script_pubkey: message.into(), + .map(|message| { + let script_pubkey = message.try_into()?; + Ok(TxOut { + value: Amount::from_sat(0), + script_pubkey, + }) }) .collect() } @@ -153,8 +160,27 @@ impl M4AckBundles { } pub fn parse_coinbase_script(script: &Script) -> IResult<&[u8], CoinbaseMessage> { - let script = script.as_bytes(); - let (input, _) = tag(&[OP_RETURN.to_u8()])(script)?; + let mut instructions = script.instructions(); + + // Return a nom parsing failure. Would be nice to include a message + // about what went wrong? Is that possible? + fn instruction_failure(instructions: Instructions) -> nom::Err> { + nom::Err::Failure(nom::error::Error { + input: instructions.as_script().as_bytes(), + code: nom::error::ErrorKind::Fail, + }) + } + + if instructions.next() != Some(Ok(Instruction::Op(OP_RETURN))) { + return Err(instruction_failure(instructions)); + } + + let Some(Ok(Instruction::PushBytes(data))) = instructions.next() else { + return Err(instruction_failure(instructions)); + }; + + let input = data.as_bytes(); + let (input, message_tag) = alt(( tag(M1_PROPOSE_SIDECHAIN_TAG), tag(M2_ACK_SIDECHAIN_TAG), @@ -288,50 +314,37 @@ pub fn parse_m8_bmm_request(input: &[u8]) -> IResult<&[u8], M8BmmRequest> { Ok((input, message)) } -impl From for ScriptBuf { - fn from(val: CoinbaseMessage) -> Self { +impl TryFrom for ScriptBuf { + type Error = bitcoin::script::PushBytesError; + + fn try_from(val: CoinbaseMessage) -> Result { match val { CoinbaseMessage::M1ProposeSidechain { sidechain_number, data, } => { - let message = [ - &[OP_RETURN.to_u8()], - M1_PROPOSE_SIDECHAIN_TAG, - &[sidechain_number], - &data, - ] - .concat(); + let message = [M1_PROPOSE_SIDECHAIN_TAG, &[sidechain_number], &data].concat(); - ScriptBuf::from_bytes(message) + let data = PushBytesBuf::try_from(message)?; + Ok(ScriptBuf::new_op_return(&data)) } CoinbaseMessage::M2AckSidechain { sidechain_number, data_hash, } => { - let message = [ - &[OP_RETURN.to_u8()], - M2_ACK_SIDECHAIN_TAG, - &[sidechain_number], - &data_hash, - ] - .concat(); + let message = [M2_ACK_SIDECHAIN_TAG, &[sidechain_number], &data_hash].concat(); - ScriptBuf::from_bytes(message) + let data = PushBytesBuf::try_from(message)?; + Ok(ScriptBuf::new_op_return(&data)) } CoinbaseMessage::M3ProposeBundle { sidechain_number, bundle_txid, } => { - let message = [ - &[OP_RETURN.to_u8()], - M3_PROPOSE_BUNDLE_TAG, - &[sidechain_number], - &bundle_txid, - ] - .concat(); + let message = [M3_PROPOSE_BUNDLE_TAG, &[sidechain_number], &bundle_txid].concat(); - ScriptBuf::from_bytes(message) + let data = PushBytesBuf::try_from(message)?; + Ok(ScriptBuf::new_op_return(&data)) } CoinbaseMessage::M4AckBundles(m4_ack_bundles) => { let upvotes = match &m4_ack_bundles { @@ -342,29 +355,24 @@ impl From for ScriptBuf { .collect(), _ => vec![], }; - let message = [ - &[OP_RETURN.to_u8()], - M4_ACK_BUNDLES_TAG, - &[m4_ack_bundles.tag()], - &upvotes, - ] - .concat(); + let message = [M4_ACK_BUNDLES_TAG, &[m4_ack_bundles.tag()], &upvotes].concat(); - ScriptBuf::from_bytes(message) + let data = PushBytesBuf::try_from(message)?; + Ok(ScriptBuf::new_op_return(&data)) } CoinbaseMessage::M7BmmAccept { sidechain_number, sidechain_block_hash, } => { let message = [ - &[OP_RETURN.to_u8()], M7_BMM_ACCEPT_TAG, &[sidechain_number], &sidechain_block_hash, ] .concat(); - ScriptBuf::from_bytes(message) + let data = PushBytesBuf::try_from(message)?; + Ok(ScriptBuf::new_op_return(&data)) } } } diff --git a/src/server.rs b/src/server.rs index 7ff1b0e..fcabd39 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,5 +1,8 @@ use std::sync::Arc; +use crate::proto::mainchain::{ + sidechain_declaration, SidechainDeclaration, SidechainDeclarationV0, +}; use crate::proto::mainchain::{ wallet_service_server::WalletService, BroadcastWithdrawalBundleRequest, BroadcastWithdrawalBundleResponse, CreateBmmCriticalDataTransactionRequest, @@ -31,7 +34,7 @@ use async_broadcast::RecvError; use bdk::bitcoin::hashes::Hash as _; use bitcoin::{self, absolute::Height, Amount, BlockHash, Transaction, TxOut}; use futures::{stream::BoxStream, StreamExt, TryStreamExt as _}; -use miette::Result; +use miette::{IntoDiagnostic, Result}; use tonic::{Request, Response, Status}; use crate::types; @@ -255,19 +258,22 @@ impl ValidatorService for Validator { } let output = messages .into_iter() - .map(|message| TxOut { - value: Amount::ZERO, - script_pubkey: message.into(), + .map(|message| { + Ok(TxOut { + value: Amount::ZERO, + script_pubkey: message.try_into().into_diagnostic()?, + }) }) - .collect(); - let transasction = Transaction { + .collect::>>() + .map_err(|err| tonic::Status::internal(err.to_string()))?; + let transaction = Transaction { output, input: vec![], lock_time: bitcoin::absolute::LockTime::Blocks(Height::ZERO), version: bitcoin::transaction::Version::TWO, }; let response = GetCoinbasePsbtResponse { - psbt: Some(ConsensusHex::encode(&transasction)), + psbt: Some(ConsensusHex::encode(&transaction)), }; Ok(Response::new(response)) } @@ -341,26 +347,24 @@ impl ValidatorService for Validator { .map_err(|err| err.into_status())?; let sidechain_proposals = sidechain_proposals .into_iter() - .map( - |( - data_hash, - types::SidechainProposal { - sidechain_number, - data, - vote_count, - proposal_height, - }, - )| { - SidechainProposal { - sidechain_number: u8::from(sidechain_number) as u32, - data: Some(data), - data_hash: Some(ConsensusHex::encode(&data_hash)), - vote_count: vote_count as u32, - proposal_height, - proposal_age: 0, + .map(|(data_hash, proposal)| SidechainProposal { + sidechain_number: u8::from(proposal.sidechain_number) as u32, + data: Some(proposal.data.clone()), + declaration: proposal.try_deserialize().ok().map(|(_, deserialized)| { + SidechainDeclaration { + version: Some(sidechain_declaration::Version::V0(SidechainDeclarationV0 { + title: Some(deserialized.title), + description: Some(deserialized.description), + hash_id_1: Some(ConsensusHex::encode(&deserialized.hash_id_1)), + hash_id_2: Some(ConsensusHex::encode(&deserialized.hash_id_2.to_vec())), + })), } - }, - ) + }), + data_hash: Some(ConsensusHex::encode(&data_hash)), + vote_count: proposal.vote_count as u32, + proposal_height: proposal.proposal_height, + proposal_age: 0, + }) .collect(); let response = GetSidechainProposalsResponse { sidechain_proposals, diff --git a/src/types.rs b/src/types.rs index 6bec884..33a81b6 100644 --- a/src/types.rs +++ b/src/types.rs @@ -2,6 +2,7 @@ use std::num::TryFromIntError; use bitcoin::{Amount, BlockHash, OutPoint, TxOut, Work}; use hashlink::LinkedHashMap; +use miette::Result; use serde::{Deserialize, Serialize}; pub type Hash256 = [u8; 32]; @@ -58,6 +59,137 @@ pub struct SidechainProposal { pub proposal_height: u32, } +impl SidechainProposal { + /// Deserialize the sidechain proposal, returning "raw" nom errors. This also means we're + /// not validating that the title and description are valid UTF-8. This is probably possible + /// with nom, but the author doesn't know how. It's fine to do this in the outer function, + /// anyways. + fn try_deserialize_unvalidated( + &self, + ) -> nom::IResult<&[u8], (u8, UnvalidatedDeserializedSidechainProposalV1)> { + use nom::bytes::complete::tag; + use nom::bytes::complete::take; + use nom::number::complete::be_u8; + + let input = self.data.as_slice(); + let (input, sidechain_number) = be_u8(input)?; + + const VERSION_0: u8 = 0; + let (input, _) = tag(&[VERSION_0])(input)?; + + let (input, title_length) = be_u8(input)?; + let (input, title) = take(title_length)(input)?; + + const HASH_ID_1_LENGTH: usize = 32; + const HASH_ID_2_LENGTH: usize = 20; + + let description_length = input.len() - HASH_ID_1_LENGTH - HASH_ID_2_LENGTH; + let (input, description) = take(description_length)(input)?; + + let (input, hash_id_1) = take(HASH_ID_1_LENGTH)(input)?; + let (input, hash_id_2) = take(HASH_ID_2_LENGTH)(input)?; + + let hash_id_1: [u8; HASH_ID_1_LENGTH] = hash_id_1.try_into().unwrap(); + let hash_id_2: [u8; HASH_ID_2_LENGTH] = hash_id_2.try_into().unwrap(); + + let parsed = UnvalidatedDeserializedSidechainProposalV1 { + title: title.to_vec(), + description: description.to_vec(), + hash_id_1, + hash_id_2, + }; + + Ok((input, (sidechain_number, parsed))) + } + + pub fn try_deserialize(&self) -> Result<(SidechainNumber, DeserializedSidechainProposalV1)> { + let (input, (sidechain_number, unvalidated)) = + self.try_deserialize_unvalidated() + .map_err(|err| match err { + nom::Err::Error(nom::error::Error { + code: nom::error::ErrorKind::Tag, + input, + }) => SidechainProposalError::UnknownVersion(input[0]), + _ => SidechainProposalError::FailedToDeserialize(err.to_owned()), + })?; + + // One might think "this would be a good place to check there's no trailing bytes". + // That doesn't work! Our parsing logic consumes _all_ bytes, because the length of the + // `description` field is defined as the total length of the entire input minus + // the length of all the other, known-length fields. We therefore _always_ read + // the entire input. + if !input.is_empty() { + panic!("somehow ended up with trailing bytes") + } + + let sidechain_number = SidechainNumber::from(sidechain_number); + + let title = String::from_utf8(unvalidated.title) + .map_err(SidechainProposalError::InvalidUtf8Title)?; + + let description = String::from_utf8(unvalidated.description) + .map_err(SidechainProposalError::InvalidUtf8Description)?; + + Ok(( + sidechain_number, + DeserializedSidechainProposalV1 { + title, + description, + hash_id_1: unvalidated.hash_id_1, + hash_id_2: unvalidated.hash_id_2, + }, + )) + } +} + +/// We know how to deserialize M1 v1 messages from BIP300 +/// https://github.com/bitcoin/bips/blob/master/bip-0300.mediawiki#m1----propose-sidechain +/// +/// 1-byte nSidechain +/// 4-byte nVersion +/// 1-byte title length +/// x-byte title +/// x-byte description +/// 32-byte hashID1 +/// 20-byte hashID2 +/// +/// Description length is total_data_length - length_of_all_other_fields +#[derive(Debug)] +pub struct DeserializedSidechainProposalV1 { + pub title: String, + pub description: String, + pub hash_id_1: [u8; 32], + pub hash_id_2: [u8; 20], +} + +struct UnvalidatedDeserializedSidechainProposalV1 { + pub title: Vec, + pub description: Vec, + pub hash_id_1: [u8; 32], + pub hash_id_2: [u8; 20], +} +use miette::Diagnostic; +use thiserror::Error; + +#[derive(Debug, Error, Diagnostic)] +pub enum SidechainProposalError { + #[error("Invalid UTF-8 sequence in title")] + #[diagnostic(code(sidechain_proposal::invalid_utf8_title))] + InvalidUtf8Title(std::string::FromUtf8Error), + + #[error("Invalid UTF-8 sequence in description")] + #[diagnostic(code(sidechain_proposal::invalid_utf8_description))] + InvalidUtf8Description(std::string::FromUtf8Error), + + #[error("Failed to deserialize sidechain proposal")] + #[diagnostic(code(sidechain_proposal::failed_to_deserialize))] + FailedToDeserialize(nom::Err>>), + + #[error("Unknown sidechain proposal version: {0}")] + #[diagnostic(code(sidechain_proposal::unknown_version))] + UnknownVersion(u8), +} + #[derive(Debug, Deserialize, Serialize)] pub struct PendingM6id { pub m6id: Hash256, @@ -130,3 +262,122 @@ pub enum Event { block_hash: BlockHash, }, } + +#[cfg(test)] +mod tests { + use crate::types::SidechainNumber; + use crate::types::SidechainProposal; + + fn proposal(data: Vec) -> SidechainProposal { + SidechainProposal { + sidechain_number: SidechainNumber(1), + data, + vote_count: 0, + proposal_height: 0, + } + } + + const EMPTY_HASH_ID_1: [u8; 32] = [1u8; 32]; + const EMPTY_HASH_ID_2: [u8; 20] = [2u8; 20]; + + #[test] + fn test_try_deserialize_valid_data() { + let sidechain_proposal = proposal(vec![ + 1, // sidechain_number + 0, // version + 5, // title length + b'H', b'e', b'l', b'l', b'o', // title + b'W', b'o', b'r', b'l', b'd', // description + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, // hash_id_1 + 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, + 1, // hash_id_2 + ]); + + let result = sidechain_proposal.try_deserialize(); + assert!(result.is_ok()); + + let (sidechain_number, deserialized) = result.unwrap(); + assert_eq!(sidechain_number, SidechainNumber::from(1)); + assert_eq!(deserialized.title, "Hello"); + assert_eq!(deserialized.description, "World"); + + let expected_hash_id_1 = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, + ]; + + let expected_hash_id_2 = [ + 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, + ]; + + assert_eq!(deserialized.hash_id_1, expected_hash_id_1); + assert_eq!(deserialized.hash_id_2, expected_hash_id_2); + } + + #[test] + fn test_try_deserialize_invalid_utf8_title() { + let sidechain_proposal = proposal( + [ + vec![ + 1, // sidechain_number + 0, // version + 5, // title length + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // invalid UTF-8 title + b'W', b'o', b'r', b'l', b'd', // description + ], + EMPTY_HASH_ID_1.to_vec(), + EMPTY_HASH_ID_2.to_vec(), + ] + .concat(), + ); + + let result = sidechain_proposal.try_deserialize(); + assert!(result.is_err()); + assert_eq!( + format!("{}", result.unwrap_err().code().unwrap()), + "sidechain_proposal::invalid_utf8_title" + ); + } + + #[test] + fn test_try_deserialize_invalid_utf8_description() { + let sidechain_proposal = proposal( + [ + vec![ + 1, // sidechain_number + 0, // version + 5, // title length + b'H', b'e', b'l', b'l', b'o', // titl + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // invalid UTF-8 description + ], + EMPTY_HASH_ID_1.to_vec(), + EMPTY_HASH_ID_2.to_vec(), + ] + .concat(), + ); + + let result = sidechain_proposal.try_deserialize(); + assert!(result.is_err()); + assert_eq!( + format!("{}", result.unwrap_err().code().unwrap()), + "sidechain_proposal::invalid_utf8_description" + ); + } + + #[test] + fn test_try_deserialize_bad_version() { + let sidechain_proposal = proposal(vec![ + 1, // sidechain_number + 1, // version + 0, // trailing byte + ]); + + let result = sidechain_proposal.try_deserialize(); + assert!(result.is_err()); + assert_eq!( + format!("{}", result.unwrap_err().code().unwrap()), + "sidechain_proposal::unknown_version" + ); + } +} diff --git a/src/validator/task.rs b/src/validator/task.rs index e81fca8..09722a7 100644 --- a/src/validator/task.rs +++ b/src/validator/task.rs @@ -198,7 +198,6 @@ enum SyncError { // Returns the serialized sidechain proposal OP_RETURN output. fn create_sidechain_proposal( sidechain_number: SidechainNumber, - version: u8, title: String, description: String, hash_1: Vec, @@ -227,17 +226,24 @@ fn create_sidechain_proposal( )); }; + // The only known M1 version. + const VERSION: u8 = 0; + let mut data: Vec = vec![]; data.push(sidechain_number.into()); - data.push(version); + data.push(VERSION); + data.push(title.len() as u8); data.extend_from_slice(title.as_bytes()); data.extend_from_slice(description.as_bytes()); data.extend_from_slice(&hash_1); data.extend_from_slice(&hash_2); - let builder = CoinbaseBuilder::new() + let Ok(builder) = CoinbaseBuilder::new() .propose_sidechain(sidechain_number.into(), &data) - .build(); + .build() + else { + return Err(anyhow::anyhow!("Failed to build sidechain proposal")); + }; let tx_out = builder.first().unwrap(); @@ -1023,61 +1029,46 @@ mod tests { #[test] fn test_roundtrip() { - let Ok(proposal) = create_sidechain_proposal( + let proposal = create_sidechain_proposal( SidechainNumber::from(13), - 0, "title".into(), "description".into(), - vec![0u8; 32], - vec![0u8; 20], - ) else { - panic!("Failed to create sidechain proposal"); - }; + vec![1u8; 32], + vec![2u8; 20], + ) + .expect("Failed to create sidechain proposal"); let tx_out = TxOut::consensus_decode(&mut Cursor::new(&proposal)).unwrap(); - let Ok((rest, message)) = parse_coinbase_script(&tx_out.script_pubkey) else { - panic!("Failed to parse sidechain proposal"); - }; + let (rest, message) = parse_coinbase_script(&tx_out.script_pubkey) + .expect("Failed to parse sidechain proposal"); assert!(rest.is_empty()); let CoinbaseMessage::M1ProposeSidechain { sidechain_number, - data: _, + data, } = message else { panic!("Failed to parse sidechain proposal"); }; assert_eq!(sidechain_number, 13); - } - #[test] - fn test_parse_m1_message() { - let hex_encoded = "020000000001010000000000000000000000000000000000000000000000000000000000000000ffffffff03023939feffffff0300f2052a01000000160014a46fddeaf98f1dd3efca296ad19fec5b067dab3a00000000000000005e6ad5e0c4af0a0a0154657374636861696e41207265616c6c792073696d706c652073696465636861696e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000986a24aa21a9ede2f61c3f71d1defd3fa999dfa36953755c690689799962b48bebd836974e8cf94c70ecc7daa2000247304402207084564296c763b6cfcaf3aeea421015559161d4ecc0feb5f9b6b3c85b8b67ae02207044d4b0650a8f3b63218ca17764d2eced822859809141aee2112ca8e3b5a2770121032b15624631d879829cf8d66b89965d264674b36d73682a69d739b9cf255b99500120000000000000000000000000000000000000000000000000000000000000000000000000"; - let mut bytes = hex::decode(hex_encoded).expect("Decoding failed"); - let transaction = Transaction::consensus_decode(&mut Cursor::new(&mut bytes)) - .expect("Deserialization failed"); - - for output in &transaction.output { - let Ok((_, message)) = parse_coinbase_script(&output.script_pubkey) else { - continue; - }; - - match message { - CoinbaseMessage::M1ProposeSidechain { - sidechain_number, - data: _, - } => { - assert_eq!(sidechain_number, 10); + let proposal = SidechainProposal { + sidechain_number: sidechain_number.into(), + data, + vote_count: 0, + proposal_height: 0, + }; - return; - } - _ => panic!("Parsed message is not an M1ProposeSidechain"), - } - } + let (sidechain_number, deserialized) = + proposal.try_deserialize().expect("Failed to deserialize"); - panic!("did not find M1ProposeSidechain"); + assert_eq!(sidechain_number, SidechainNumber(13)); + assert_eq!(deserialized.description, "description"); + assert_eq!(deserialized.title, "title"); + assert_eq!(deserialized.hash_id_1, [1u8; 32]); + assert_eq!(deserialized.hash_id_2, [2u8; 20]); } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 044a90e..7aaa979 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -335,7 +335,7 @@ impl Wallet { for (sidechain_number, bmm_hash) in &bmm_hashes { coinbase_builder = coinbase_builder.bmm_accept(*sidechain_number, bmm_hash); } - let coinbase_outputs = coinbase_builder.build(); + let coinbase_outputs = coinbase_builder.build().into_diagnostic()?; let deposits = self.get_pending_deposits(None)?; let deposit_transactions = deposits .into_iter()