Browse Source

monero: Remove sponge-over-wire and add CoinbasePrefix

x 1 day ago
parent
commit
332cd3df2f

+ 1 - 1
src/blockchain/header_store.rs

@@ -174,7 +174,7 @@ impl Header {
                 }
 
                 // Verify that MoneroPowData correctly corresponds to this header.
-                let Ok(Some(merkle_root)) = extract_aux_merkle_root(&powdata.coinbase_tx_extra)
+                let Ok(Some(merkle_root)) = extract_aux_merkle_root(powdata.coinbase_tx_extra())
                 else {
                     return false
                 };

+ 377 - 0
src/blockchain/monero/coinbase.rs

@@ -0,0 +1,377 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::io::{self, Read};
+
+use monero::{
+    blockdata::transaction::{TxIn, TxOut, TxOutTarget},
+    consensus::Encodable,
+    VarInt,
+};
+
+pub(super) const MAX_COINBASE_PREFIX_SIZE: usize = 65536;
+pub(super) const MAX_COINBASE_OUTPUTS: usize = 1000;
+
+/// Canonical version-2 generation transaction fields ending before extra's length.
+/// Validation is structural: keys are raw bytes, not checked curve points.
+#[derive(Clone, Debug)]
+pub(super) struct CoinbasePrefix {
+    bytes: Vec<u8>,
+}
+
+impl CoinbasePrefix {
+    pub(super) fn new(bytes: Vec<u8>) -> io::Result<Self> {
+        if bytes.len() > MAX_COINBASE_PREFIX_SIZE {
+            return Err(io::Error::new(io::ErrorKind::InvalidData, "Coinbase prefix too large"))
+        }
+
+        let mut reader = bytes.as_slice();
+        let version = read_monero_varint(&mut reader)?;
+        if version.0 != 2 {
+            return Err(io::Error::new(io::ErrorKind::InvalidData, "Unsupported coinbase version"))
+        }
+        let unlock_time = read_monero_varint(&mut reader)?;
+        let input_count = read_monero_varint(&mut reader)?;
+        if input_count.0 != 1 {
+            return Err(io::Error::new(io::ErrorKind::InvalidData, "Expected one generation input"))
+        }
+        let mut tag = [0u8; 1];
+        reader.read_exact(&mut tag)?;
+        if tag != [0xff] {
+            return Err(io::Error::new(io::ErrorKind::InvalidData, "Expected generation input tag"))
+        }
+        let height = read_monero_varint(&mut reader)?;
+        let output_count = read_monero_varint(&mut reader)?;
+        if !(1..=MAX_COINBASE_OUTPUTS as u64).contains(&output_count.0) {
+            return Err(io::Error::new(io::ErrorKind::InvalidData, "Invalid coinbase output count"))
+        }
+
+        // No dependency vector/transaction decoder is used. Re-encoding cannot
+        // grow this buffer, and no collection is allocated from a declared count.
+        let mut canonical = vec![0u8; bytes.len()];
+        let mut writer = canonical.as_mut_slice();
+        version.consensus_encode(&mut writer)?;
+        unlock_time.consensus_encode(&mut writer)?;
+        input_count.consensus_encode(&mut writer)?;
+        TxIn::Gen { height }.consensus_encode(&mut writer)?;
+        output_count.consensus_encode(&mut writer)?;
+
+        for _ in 0..output_count.0 {
+            let amount = read_monero_varint(&mut reader)?;
+            reader.read_exact(&mut tag)?;
+            if tag != [0x02] && tag != [0x03] {
+                return Err(io::Error::new(io::ErrorKind::InvalidData, "Unsupported output target"))
+            }
+            let mut key = [0u8; 32];
+            reader.read_exact(&mut key)?;
+            let target = if tag == [0x02] {
+                TxOutTarget::ToKey { key }
+            } else {
+                let mut view_tag = [0u8; 1];
+                reader.read_exact(&mut view_tag)?;
+                let [view_tag] = view_tag;
+                TxOutTarget::ToTaggedKey { key, view_tag }
+            };
+            TxOut { amount, target }.consensus_encode(&mut writer)?;
+        }
+
+        if !reader.is_empty() || !writer.is_empty() || canonical != bytes {
+            return Err(io::Error::new(io::ErrorKind::InvalidData, "Noncanonical coinbase prefix"))
+        }
+        Ok(Self { bytes })
+    }
+
+    pub(super) fn as_bytes(&self) -> &[u8] {
+        &self.bytes
+    }
+}
+
+/// Read one minimal unsigned Monero varint without allocating or reading past
+/// its tenth byte. The dependency decoder collects bytes before bounding them.
+pub(super) fn read_monero_varint<R: Read>(reader: &mut R) -> io::Result<VarInt> {
+    let mut value = 0u64;
+    for index in 0..10 {
+        let mut byte = [0u8; 1];
+        reader.read_exact(&mut byte)?;
+        let [byte] = byte;
+        if index == 9 && byte > 1 {
+            return Err(io::ErrorKind::InvalidData.into())
+        }
+        value |= u64::from(byte & 0x7f) << (7 * index);
+        if byte & 0x80 == 0 {
+            if index != 0 && byte == 0 {
+                return Err(io::ErrorKind::InvalidData.into())
+            }
+            return Ok(VarInt(value))
+        }
+    }
+    Err(io::ErrorKind::InvalidData.into())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    // Each transition in the unsigned base-128 encoding, including bit 63.
+    fn scalar_boundaries() -> Vec<u64> {
+        let mut values = vec![0, 1, 2, 999, 1000, 1001, u64::MAX];
+        for shift in (7..=63).step_by(7) {
+            let boundary = 1u64 << shift;
+            values.extend([boundary - 1, boundary, boundary + 1]);
+        }
+        values
+    }
+
+    fn scalar(value: u64) -> io::Result<Vec<u8>> {
+        let mut bytes = Vec::new();
+        VarInt(value).consensus_encode(&mut bytes)?;
+        Ok(bytes)
+    }
+
+    fn output(amount: u64, tagged: bool) -> TxOut {
+        let key = [0xff; 32];
+        let target = if tagged {
+            TxOutTarget::ToTaggedKey { key, view_tag: 0xff }
+        } else {
+            TxOutTarget::ToKey { key }
+        };
+        TxOut { amount: VarInt(amount), target }
+    }
+
+    fn prefix(unlock_time: u64, height: u64, outputs: Vec<TxOut>) -> io::Result<Vec<u8>> {
+        let mut bytes = Vec::new();
+        VarInt(2).consensus_encode(&mut bytes)?;
+        VarInt(unlock_time).consensus_encode(&mut bytes)?;
+        vec![TxIn::Gen { height: VarInt(height) }].consensus_encode(&mut bytes)?;
+        outputs.consensus_encode(&mut bytes)?;
+        Ok(bytes)
+    }
+
+    fn assert_invalid(bytes: Vec<u8>) {
+        assert!(matches!(
+            CoinbasePrefix::new(bytes),
+            Err(error) if error.kind() == io::ErrorKind::InvalidData
+        ));
+    }
+
+    #[test]
+    fn varint_matches_monero_at_every_scalar_boundary() -> io::Result<()> {
+        for value in scalar_boundaries() {
+            let encoded = scalar(value)?;
+            assert!((1..=10).contains(&encoded.len()));
+            let mut reader = encoded.as_slice();
+            assert_eq!(read_monero_varint(&mut reader)?, VarInt(value));
+            assert!(reader.is_empty());
+            for end in 0..encoded.len() {
+                let mut truncated = &encoded[..end];
+                assert!(matches!(
+                    read_monero_varint(&mut truncated),
+                    Err(error) if error.kind() == io::ErrorKind::UnexpectedEof
+                ));
+            }
+            let mut followed = encoded;
+            followed.push(0x42);
+            let mut reader = followed.as_slice();
+            assert_eq!(read_monero_varint(&mut reader)?, VarInt(value));
+            assert_eq!(reader, &[0x42]);
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn varint_rejects_nonminimal_overflow_and_continuation() {
+        for length in 2..=10 {
+            let mut bytes = vec![0x80; length - 1];
+            bytes.push(0);
+            assert!(matches!(
+                read_monero_varint(&mut bytes.as_slice()),
+                Err(error) if error.kind() == io::ErrorKind::InvalidData
+            ));
+        }
+        // Exhaust all invalid tenth bytes, including continuations with a
+        // payload of zero or one. An eleventh byte must never be consumed.
+        for last in 2..=u8::MAX {
+            let mut bytes = vec![0xff; 9];
+            bytes.extend([last, 0x42]);
+            let mut reader = bytes.as_slice();
+            assert!(matches!(
+                read_monero_varint(&mut reader),
+                Err(error) if error.kind() == io::ErrorKind::InvalidData
+            ));
+            assert_eq!(reader, &[0x42]);
+        }
+    }
+
+    #[test]
+    fn both_targets_match_monero_encoding_with_raw_keys() -> io::Result<()> {
+        for tagged in [false, true] {
+            let mut expected = vec![2, 0, 1, 0xff, 0, 1, 0];
+            expected.push(if tagged { 3 } else { 2 });
+            expected.extend([0xff; 32]);
+            if tagged {
+                expected.push(0xff);
+            }
+            let encoded = prefix(0, 0, vec![output(0, tagged)])?;
+            assert_eq!(encoded, expected);
+            let validated = CoinbasePrefix::new(encoded)?;
+            assert_eq!(validated.as_bytes(), expected);
+        }
+        let mixed = prefix(42, 123456, vec![output(128, false), output(u64::MAX, true)])?;
+        assert_eq!(CoinbasePrefix::new(mixed.clone())?.as_bytes(), mixed);
+        Ok(())
+    }
+
+    #[test]
+    fn multibyte_prefix_scalars_are_accepted() -> io::Result<()> {
+        // Unlock time, generation height, and amount; counts are tested below.
+        for offset in [1, 4, 6] {
+            let mut bytes = prefix(0, 0, vec![output(0, false)])?;
+            bytes.splice(offset..offset + 1, scalar(128)?);
+            assert_eq!(CoinbasePrefix::new(bytes.clone())?.as_bytes(), bytes);
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn malformed_varints_at_every_prefix_position() -> io::Result<()> {
+        let baseline = prefix(0, 0, vec![output(0, false)])?;
+        // Version, unlock time, input count, height, output count, and amount.
+        for offset in [0, 1, 2, 4, 5, 6] {
+            // Same-value nonminimal encoding, tenth-byte overflow, and continuation.
+            for malformed in [
+                vec![baseline[offset] | 0x80, 0],
+                [vec![0xff; 9], vec![2]].concat(),
+                vec![0x80; 10],
+            ] {
+                let mut bytes = baseline.clone();
+                bytes.splice(offset..offset + 1, malformed);
+                assert_invalid(bytes);
+            }
+            let mut truncated = baseline[..offset].to_vec();
+            truncated.push(0x80);
+            assert!(matches!(
+                CoinbasePrefix::new(truncated),
+                Err(error) if error.kind() == io::ErrorKind::UnexpectedEof
+            ));
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn unsupported_tags_fail_before_nested_payloads() -> io::Result<()> {
+        for tag in 0..=u8::MAX {
+            if tag != 0xff {
+                let mut bytes = vec![2, 0, 1, tag];
+                assert_invalid(bytes.clone());
+                // A ToKey input could otherwise enter a ring-offset vector decoder.
+                bytes.push(0);
+                bytes.extend(scalar(u64::MAX)?);
+                assert_invalid(bytes);
+            }
+            if tag != 2 && tag != 3 {
+                assert_invalid(vec![2, 0, 1, 0xff, 0, 1, 0, tag]);
+            }
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn version_and_counts_are_checked_before_payloads() -> io::Result<()> {
+        for version in [0, 1, 3, 128, u64::MAX] {
+            assert_invalid(scalar(version)?);
+        }
+        for count in [0, 2, 1000, 1001, u32::MAX as u64, u64::MAX] {
+            let mut bytes = vec![2, 0];
+            bytes.extend(scalar(count)?);
+            assert_invalid(bytes);
+        }
+        for count in [0, 1001, u32::MAX as u64, u64::MAX] {
+            let mut bytes = vec![2, 0, 1, 0xff, 0];
+            bytes.extend(scalar(count)?);
+            assert_invalid(bytes);
+        }
+        for count in [1, MAX_COINBASE_OUTPUTS] {
+            let mut bytes = vec![2, 0, 1, 0xff, 0];
+            bytes.extend(scalar(count as u64)?);
+            assert!(matches!(
+                CoinbasePrefix::new(bytes),
+                Err(error) if error.kind() == io::ErrorKind::UnexpectedEof
+            ));
+        }
+        for tagged in [false, true] {
+            for count in [1, 127, 128, MAX_COINBASE_OUTPUTS] {
+                let bytes = prefix(0, 0, vec![output(0, tagged); count])?;
+                assert_eq!(CoinbasePrefix::new(bytes.clone())?.as_bytes(), bytes);
+            }
+            assert_invalid(prefix(0, 0, vec![output(0, tagged); MAX_COINBASE_OUTPUTS + 1])?);
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn maximum_grammar_length_and_oversized_prefix() -> io::Result<()> {
+        let bytes = prefix(u64::MAX, u64::MAX, vec![output(u64::MAX, true); MAX_COINBASE_OUTPUTS])?;
+        assert_eq!(bytes.len(), 44025);
+        assert_eq!(CoinbasePrefix::new(bytes.clone())?.as_bytes(), bytes);
+        for length in [44026, MAX_COINBASE_PREFIX_SIZE, MAX_COINBASE_PREFIX_SIZE + 1] {
+            let mut padded = bytes.clone();
+            padded.resize(length, 0);
+            assert_invalid(padded);
+        }
+        assert_invalid(vec![0x80; MAX_COINBASE_PREFIX_SIZE + 1]);
+        Ok(())
+    }
+
+    #[test]
+    fn every_truncation_of_supported_targets_is_rejected() -> io::Result<()> {
+        for tagged in [false, true] {
+            // A two-byte output count also exercises truncation inside its varint.
+            let bytes = prefix(u64::MAX, u64::MAX, vec![output(u64::MAX, tagged); 128])?;
+            for end in 0..bytes.len() {
+                assert!(CoinbasePrefix::new(bytes[..end].to_vec()).is_err(), "end {end}");
+            }
+        }
+        Ok(())
+    }
+
+    #[test]
+    fn extra_framing_trailing_bytes_and_split_shifts_are_rejected() -> io::Result<()> {
+        for tagged in [false, true] {
+            let bytes = prefix(0, 0, vec![output(0, tagged)])?;
+            // Empty extra, nonempty extra, and the full null-RingCT tail.
+            for tail in [vec![0], vec![1, 0x42], vec![1, 0x42, 0], vec![0x80, 1]] {
+                let mut with_tail = bytes.clone();
+                with_tail.extend(tail);
+                assert_invalid(with_tail);
+            }
+            let mut extra = scalar(128)?;
+            extra.extend([0x42; 128]);
+            let complete = [bytes.as_slice(), extra.as_slice()].concat();
+            // Move every possible number of prefix bytes into extra or extra
+            // bytes into the prefix, without changing their concatenation.
+            for split in 0..=complete.len() {
+                if split != bytes.len() {
+                    assert!(CoinbasePrefix::new(complete[..split].to_vec()).is_err());
+                }
+            }
+            let framed = [scalar(bytes.len() as u64)?.as_slice(), bytes.as_slice()].concat();
+            assert_invalid(framed);
+        }
+        Ok(())
+    }
+}

+ 0 - 107
src/blockchain/monero/keccak.rs

@@ -1,107 +0,0 @@
-/* This file is part of DarkFi (https://dark.fi)
- *
- * Copyright (C) 2020-2026 Dyne.org foundation
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-use std::io::{Cursor, Read, Result, Write};
-
-#[allow(unused_imports)]
-use tiny_keccak::{Hasher, Keccak};
-
-#[repr(C)]
-#[allow(unused)]
-enum Mode {
-    Absorbing,
-    Squeezing,
-}
-
-#[repr(C)]
-// https://docs.rs/tiny-keccak/latest/src/tiny_keccak/lib.rs.html#368
-struct KeccakState {
-    buffer: [u8; 200],
-    offset: usize,
-    rate: usize,
-    delim: u8,
-    mode: Mode,
-}
-
-unsafe fn serialize_keccak<W: Write>(keccak: &Keccak, writer: &mut W) -> Result<()> {
-    let keccak_ptr = keccak as *const Keccak as *const KeccakState;
-    let keccak_state = &*keccak_ptr;
-
-    writer.write_all(&keccak_state.buffer)?;
-    writer.write_all(&(keccak_state.offset as u64).to_le_bytes())?;
-    writer.write_all(&(keccak_state.rate as u64).to_le_bytes())?;
-    writer.write_all(&[keccak_state.delim])?;
-
-    Ok(())
-}
-
-unsafe fn deserialize_keccak<R: Read>(reader: &mut R) -> Result<Keccak> {
-    let mut keccak = Keccak::v256();
-
-    let keccak_ptr = &mut keccak as *mut Keccak as *mut KeccakState;
-    let keccak_state = &mut *keccak_ptr;
-
-    reader.read_exact(&mut keccak_state.buffer)?;
-
-    let mut offset_bytes = [0u8; 8];
-    reader.read_exact(&mut offset_bytes)?;
-    keccak_state.offset = u64::from_le_bytes(offset_bytes) as usize;
-
-    let mut rate_bytes = [0u8; 8];
-    reader.read_exact(&mut rate_bytes)?;
-    keccak_state.rate = u64::from_le_bytes(rate_bytes) as usize;
-
-    let mut delim_byte = [0u8; 1];
-    reader.read_exact(&mut delim_byte)?;
-    keccak_state.delim = delim_byte[0];
-
-    keccak_state.mode = Mode::Absorbing;
-
-    Ok(keccak)
-}
-
-pub fn keccak_to_bytes(keccak: &Keccak) -> Vec<u8> {
-    let mut bytes = vec![];
-    unsafe { serialize_keccak(keccak, &mut bytes).unwrap() }
-    bytes
-}
-
-pub fn keccak_from_bytes(bytes: &[u8]) -> Keccak {
-    let mut cursor = Cursor::new(bytes);
-    unsafe { deserialize_keccak(&mut cursor).unwrap() }
-}
-
-#[test]
-fn test_keccak_serde() {
-    let mut keccak = Keccak::v256();
-    keccak.update(b"foobar");
-
-    let ser = keccak_to_bytes(&keccak);
-
-    let mut digest1 = [0u8; 32];
-    keccak.finalize(&mut digest1);
-
-    let de = keccak_from_bytes(&ser);
-    let mut digest2 = [0u8; 32];
-    de.finalize(&mut digest2);
-
-    println!("{digest1:?}");
-    println!("{digest2:?}");
-
-    assert_eq!(digest1, digest2);
-}

+ 110 - 34
src/blockchain/monero/merkle_proof.rs

@@ -80,6 +80,9 @@ impl AsyncEncodable for MerkleProof {
 impl Decodable for MerkleProof {
     fn decode<D: Read>(d: &mut D) -> io::Result<Self> {
         let len: u8 = d.read_u8()?;
+        if len as usize >= MAX_MERKLE_TREE_PROOF_SIZE {
+            return Err(Error::new(io::ErrorKind::InvalidData, "Invalid Merkle proof branch length"))
+        }
         let mut branch = Vec::with_capacity(len as usize);
 
         for _ in 0..len {
@@ -97,6 +100,9 @@ impl Decodable for MerkleProof {
 impl AsyncDecodable for MerkleProof {
     async fn decode_async<D: AsyncRead + Unpin + Send>(d: &mut D) -> io::Result<Self> {
         let len: u8 = d.read_u8_async().await?;
+        if len as usize >= MAX_MERKLE_TREE_PROOF_SIZE {
+            return Err(Error::new(io::ErrorKind::InvalidData, "Invalid Merkle proof branch length"))
+        }
         let mut branch = Vec::with_capacity(len as usize);
 
         for _ in 0..len {
@@ -211,6 +217,110 @@ mod tests {
         *,
     };
 
+    struct LengthOnlyReader(Option<u8>);
+
+    impl Read for LengthOnlyReader {
+        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+            assert_eq!(buf.len(), 1);
+            buf[0] = self.0.take().expect("decoder must not read beyond the invalid length");
+            Ok(1)
+        }
+    }
+
+    #[cfg(feature = "async-serial")]
+    impl AsyncRead for LengthOnlyReader {
+        fn poll_read(
+            mut self: std::pin::Pin<&mut Self>,
+            _cx: &mut std::task::Context<'_>,
+            buf: &mut [u8],
+        ) -> std::task::Poll<io::Result<usize>> {
+            std::task::Poll::Ready(Read::read(&mut *self, buf))
+        }
+    }
+
+    #[test]
+    fn test_merkleproof_serde_valid_lengths_and_truncation() {
+        for len in [0u8, 1, 31] {
+            let branch = (0..len).map(|i| Hash::from([i; 32])).collect::<Vec<_>>();
+            let path = 0x89ab_cdefu32;
+            let proof = MerkleProof::try_construct(branch, path).unwrap();
+            let mut wire = vec![len];
+            for i in 0..len {
+                wire.extend_from_slice(&[i; 32]);
+            }
+            wire.extend_from_slice(&path.to_le_bytes());
+            let ser_sync = darkfi_serial::serialize(&proof);
+            assert_eq!(ser_sync, wire);
+
+            let decoded: MerkleProof = darkfi_serial::deserialize(&ser_sync).unwrap();
+            assert_eq!(decoded.branch(), proof.branch());
+            assert_eq!(decoded.path(), path);
+            for end in 0..wire.len() {
+                assert!(darkfi_serial::deserialize::<MerkleProof>(&wire[..end]).is_err());
+            }
+
+            #[cfg(feature = "async-serial")]
+            smol::future::block_on(async {
+                let ser_async = darkfi_serial::serialize_async(&proof).await;
+                assert_eq!(ser_async, ser_sync);
+                let decoded_async: MerkleProof =
+                    darkfi_serial::deserialize_async(&ser_sync).await.unwrap();
+                assert_eq!(decoded_async.branch(), decoded.branch());
+                assert_eq!(decoded_async.path(), decoded.path());
+                for end in 0..wire.len() {
+                    assert!(darkfi_serial::deserialize_async::<MerkleProof>(&wire[..end])
+                        .await
+                        .is_err());
+                }
+            });
+        }
+    }
+
+    #[test]
+    fn test_merkleproof_decode_rejects_length_before_payload() {
+        for len in [32u8, 33, 255] {
+            assert!(MerkleProof::try_construct(vec![Hash::null(); len as usize], 0).is_none());
+            let mut reader = LengthOnlyReader(Some(len));
+            let err = MerkleProof::decode(&mut reader).unwrap_err();
+            assert_eq!(err.kind(), io::ErrorKind::InvalidData);
+            assert!(reader.0.is_none());
+
+            #[cfg(feature = "async-serial")]
+            smol::future::block_on(async {
+                let mut reader = LengthOnlyReader(Some(len));
+                let async_err = MerkleProof::decode_async(&mut reader).await.unwrap_err();
+                assert_eq!(async_err.kind(), err.kind());
+                assert!(reader.0.is_none());
+            });
+        }
+    }
+
+    #[test]
+    fn test_merkleproof_maximal_branch_root() {
+        let branch = (0..31).map(|i| Hash::from([i; 32])).collect::<Vec<_>>();
+        let leaf = Hash::from([0xff; 32]);
+        for path in [0, 0x8000_0000, u32::MAX] {
+            // At depth 31, the top bitmap bit is unused; the other bits select each side.
+            let expected = branch.iter().fold(leaf, |root, sibling| {
+                if path == u32::MAX {
+                    cn_fast_hash2(sibling, &root)
+                } else {
+                    cn_fast_hash2(&root, sibling)
+                }
+            });
+            let proof = MerkleProof::try_construct(branch.clone(), path).unwrap();
+            let wire = darkfi_serial::serialize(&proof);
+            let decoded: MerkleProof = darkfi_serial::deserialize(&wire).unwrap();
+            assert_eq!(decoded.calculate_root(&leaf), expected);
+
+            #[cfg(feature = "async-serial")]
+            smol::future::block_on(async {
+                let decoded: MerkleProof = darkfi_serial::deserialize_async(&wire).await.unwrap();
+                assert_eq!(decoded.calculate_root(&leaf), expected);
+            });
+        }
+    }
+
     #[test]
     fn test_empty_hashset_has_no_proof() {
         assert!(create_merkle_proof(&[], &Hash::null()).is_none());
@@ -372,38 +482,4 @@ mod tests {
         assert!(!proof.branch().contains(hash));
         assert!(!proof.branch().contains(&expected_root));
     }
-
-    // Test that both sync and async serialization formats match.
-    // We do some hacks because Monero lib doesn't do async.
-    #[test]
-    fn test_monero_merkleproof_serde() {
-        let tx_hashes = &[
-            "d96756959949db23764592fea0bfe88c790e1fd131dabb676948b343aa9ecc24",
-            "77d1a87df131c36da4832a7ec382db9b8fe947576a60ec82cc1c66a220f6ee42",
-        ]
-        .iter()
-        .map(|hash| Hash::from_str(hash).unwrap())
-        .collect::<Vec<_>>();
-
-        let proof = create_merkle_proof(tx_hashes, &tx_hashes[0]).unwrap();
-
-        let local_ex = smol::LocalExecutor::new();
-
-        let ser_sync = darkfi_serial::serialize(&proof);
-        let ser_async = smol::future::block_on(
-            local_ex.run(async { darkfi_serial::serialize_async(&proof).await }),
-        );
-
-        assert_eq!(ser_sync, ser_async);
-
-        let de_sync: MerkleProof = darkfi_serial::deserialize(&ser_async).unwrap();
-        let de_async: MerkleProof = smol::future::block_on(
-            local_ex.run(async { darkfi_serial::deserialize_async(&ser_sync).await.unwrap() }),
-        );
-
-        assert_eq!(de_sync.branch, proof.branch);
-        assert_eq!(de_async.branch, proof.branch);
-        assert_eq!(de_sync.path_bitmap, proof.path_bitmap);
-        assert_eq!(de_async.path_bitmap, proof.path_bitmap);
-    }
 }

+ 202 - 170
src/blockchain/monero/mod.rs

@@ -24,13 +24,16 @@ use std::{
 
 use darkfi_sdk::{hex::decode_hex, AsHex};
 #[cfg(feature = "async-serial")]
-use darkfi_serial::{async_trait, AsyncDecodable, AsyncEncodable, AsyncRead, AsyncWrite};
+use darkfi_serial::{
+    async_trait, AsyncDecodable, AsyncEncodable, AsyncRead, AsyncWrite, FutAsyncReadExt,
+    FutAsyncWriteExt,
+};
 use darkfi_serial::{Decodable, Encodable};
 use monero::{
     blockdata::transaction::{ExtraField, RawExtraField, SubField},
     consensus::{Decodable as XmrDecodable, Encodable as XmrEncodable},
     cryptonote::hash::Hashable,
-    util::ringct::{RctSigBase, RctType},
+    util::ringct::RctType,
     BlockHeader,
 };
 use tiny_keccak::{Hasher, Keccak};
@@ -44,8 +47,10 @@ use fixed_array::{FixedByteArray, MaxSizeVec};
 pub mod merkle_proof;
 use merkle_proof::MerkleProof;
 
-pub mod keccak;
-use keccak::{keccak_from_bytes, keccak_to_bytes};
+mod coinbase;
+use coinbase::{
+    read_monero_varint, CoinbasePrefix, MAX_COINBASE_OUTPUTS, MAX_COINBASE_PREFIX_SIZE,
+};
 
 pub mod utils;
 use utils::{create_blockhashing_blob, create_merkle_proof, tree_hash};
@@ -53,8 +58,16 @@ use utils::{create_blockhashing_blob, create_merkle_proof, tree_hash};
 pub mod merkle_tree_parameters;
 pub use merkle_tree_parameters::MerkleTreeParameters;
 
+#[cfg(test)]
+mod tests;
+
 pub type AuxChainHashes = MaxSizeVec<monero::Hash, 128>;
 
+// Reset-only encoding: there is deliberately no legacy sponge-state decoder.
+const POW_DATA_FORMAT: [u8; 8] = *b"DFMM\x00\x00\x00\x01";
+const MAX_MONERO_HEADER_SIZE: usize = 128;
+const MAX_COINBASE_EXTRA_SIZE: usize = 65536;
+
 /// This struct represents all the Proof of Work information required
 /// for merge mining.
 #[derive(Clone)]
@@ -65,15 +78,17 @@ pub struct MoneroPowData {
     pub randomx_key: FixedByteArray,
     /// The number of transactions included in this Monero block.
     /// This is used to produce the blockhashing_blob.
-    pub transaction_count: u16,
+    transaction_count: u16,
     /// Transaction root
     pub merkle_root: monero::Hash,
     /// Coinbase Merkle proof hashes
     pub coinbase_merkle_proof: MerkleProof,
-    /// Incomplete hashed state of the coinbase transaction
-    pub coinbase_tx_hasher: Keccak,
-    /// Extra field of the coinbase
-    pub coinbase_tx_extra: RawExtraField,
+    /// Canonical coinbase fields ending before extra's length. Only validated
+    /// constructors can populate these bytes; no received hash state is trusted.
+    coinbase_tx_prefix: CoinbasePrefix,
+    /// Complete raw extra, bounded at construction/decoding and not mutable
+    /// through the public API. Hashing must not use a parsed subset of it.
+    coinbase_tx_extra: RawExtraField,
     /// Aux chain Merkle proof hashes
     pub aux_chain_merkle_proof: MerkleProof,
 }
@@ -85,6 +100,37 @@ impl MoneroPowData {
         seed: FixedByteArray,
         aux_chain_merkle_proof: MerkleProof,
     ) -> Result<Self> {
+        // Bound collections before cloning, hashing, or encoding them. In
+        // particular, do not hash an unsupported RingCT transaction first.
+        let transaction_count = block
+            .tx_hashes
+            .len()
+            .checked_add(1)
+            .and_then(|count| u16::try_from(count).ok())
+            .ok_or_else(|| Error::other("Too many Monero transactions"))?;
+        let coinbase = &block.miner_tx;
+        if coinbase.prefix.version != monero::VarInt(2) ||
+            coinbase.prefix.inputs.len() != 1 ||
+            !(1..=MAX_COINBASE_OUTPUTS).contains(&coinbase.prefix.outputs.len()) ||
+            coinbase.prefix.extra.0.len() > MAX_COINBASE_EXTRA_SIZE ||
+            !matches!(
+                coinbase.prefix.inputs.first(),
+                Some(monero::blockdata::transaction::TxIn::Gen { .. })
+            ) ||
+            !coinbase.signatures.is_empty() ||
+            !matches!(&coinbase.rct_signatures.sig, Some(base) if base.rct_type == RctType::Null) ||
+            coinbase.rct_signatures.p.is_some()
+        {
+            return Err(Error::other("Unsupported Monero coinbase").into())
+        }
+
+        let mut encoder_prefix = vec![];
+        coinbase.prefix.version.consensus_encode(&mut encoder_prefix)?;
+        coinbase.prefix.unlock_time.consensus_encode(&mut encoder_prefix)?;
+        coinbase.prefix.inputs.consensus_encode(&mut encoder_prefix)?;
+        coinbase.prefix.outputs.consensus_encode(&mut encoder_prefix)?;
+        let coinbase_tx_prefix = CoinbasePrefix::new(encoder_prefix)?;
+
         let hashes = create_ordered_tx_hashes_from_block(&block);
         let root = tree_hash(&hashes)?;
         let hash =
@@ -96,59 +142,76 @@ impl MoneroPowData {
             )
         })?;
 
-        let coinbase = block.miner_tx.clone();
-
-        let mut keccak = Keccak::v256();
-        let mut encoder_prefix = vec![];
-        coinbase.prefix.version.consensus_encode(&mut encoder_prefix)?;
-        coinbase.prefix.unlock_time.consensus_encode(&mut encoder_prefix)?;
-        coinbase.prefix.inputs.consensus_encode(&mut encoder_prefix)?;
-        coinbase.prefix.outputs.consensus_encode(&mut encoder_prefix)?;
-        keccak.update(&encoder_prefix);
-
-        Ok(Self {
+        let expected_coinbase_hash = *hash;
+        let powdata = Self {
             header: block.header,
             randomx_key: seed,
-            transaction_count: hashes.len() as u16,
+            transaction_count,
             merkle_root: root,
             coinbase_merkle_proof,
             coinbase_tx_extra: block.miner_tx.prefix.extra,
-            coinbase_tx_hasher: keccak,
+            coinbase_tx_prefix,
             aux_chain_merkle_proof,
-        })
+        };
+        if powdata.coinbase_hash()? != expected_coinbase_hash {
+            return Err(Error::other("Monero coinbase hash mismatch").into())
+        }
+        Ok(powdata)
     }
 
-    /// Returns `true` if the coinbase Merkle proof produces the `merkle_root`
-    /// hash, otherwise `false`.
-    pub fn is_coinbase_valid_merkle_root(&self) -> bool {
-        let mut finalised_prefix_keccak = self.coinbase_tx_hasher.clone();
-        let mut encoder_extra_field = vec![];
-
-        self.coinbase_tx_extra.consensus_encode(&mut encoder_extra_field).unwrap();
-        finalised_prefix_keccak.update(&encoder_extra_field);
-        let mut prefix_hash: [u8; 32] = [0u8; 32];
-        finalised_prefix_keccak.finalize(&mut prefix_hash);
-
-        let final_prefix_hash = monero::Hash::from_slice(&prefix_hash);
-
-        // let mut finalised_keccak = Keccak::v256();
-        let rct_sig_base = RctSigBase {
-            rct_type: RctType::Null,
-            txn_fee: Default::default(),
-            pseudo_outs: vec![],
-            ecdh_info: vec![],
-            out_pk: vec![],
-        };
+    /// The number of Monero transactions, including the coinbase.
+    pub fn transaction_count(&self) -> u16 {
+        self.transaction_count
+    }
 
-        let hashes = vec![final_prefix_hash, rct_sig_base.hash(), monero::Hash::null()];
+    /// Validated coinbase bytes, excluding extra's length and contents.
+    pub fn coinbase_tx_prefix(&self) -> &[u8] {
+        self.coinbase_tx_prefix.as_bytes()
+    }
 
-        let encoder_final: Vec<u8> =
-            hashes.into_iter().flat_map(|h| Vec::from(&h.to_bytes()[..])).collect();
+    /// The same raw extra bytes used in hashing and auxiliary-tag extraction.
+    pub fn coinbase_tx_extra(&self) -> &RawExtraField {
+        &self.coinbase_tx_extra
+    }
 
-        let coinbase_hash = monero::Hash::new(encoder_final);
+    fn coinbase_prefix_hash(&self) -> io::Result<monero::Hash> {
+        let mut keccak = Keccak::v256();
+        keccak.update(self.coinbase_tx_prefix.as_bytes());
+        // The DarkFi wire length is CompactSize; the hash needs Monero's
+        // distinct base-128 encoding, followed by ALL of the raw extra.
+        let mut extra_length = vec![];
+        monero::VarInt(self.coinbase_tx_extra.0.len() as u64)
+            .consensus_encode(&mut extra_length)?;
+        keccak.update(&extra_length);
+        keccak.update(&self.coinbase_tx_extra.0);
+        let mut prefix_hash = [0u8; 32];
+        keccak.finalize(&mut prefix_hash);
+        Ok(monero::Hash::from(prefix_hash))
+    }
+
+    fn coinbase_hash(&self) -> io::Result<monero::Hash> {
+        let prefix_hash = self.coinbase_prefix_hash()?;
+        // Version-2/null-RingCT: the base hash is K(00), NOT zero or K(empty).
+        // Only the prunable hash is all zero. No vector framing enters K.
+        let null_base_hash = monero::Hash::new([0x00]);
+        let mut keccak = Keccak::v256();
+        keccak.update(prefix_hash.as_bytes());
+        keccak.update(null_base_hash.as_bytes());
+        keccak.update(monero::Hash::null().as_bytes());
+        let mut hash = [0u8; 32];
+        keccak.finalize(&mut hash);
+        Ok(monero::Hash::from(hash))
+    }
 
+    /// Returns `true` if the coinbase Merkle proof produces the `merkle_root`
+    /// hash, otherwise `false`.
+    pub fn is_coinbase_valid_merkle_root(&self) -> bool {
+        if !self.coinbase_merkle_proof.check_coinbase_path() {
+            return false
+        }
+        let Ok(coinbase_hash) = self.coinbase_hash() else { return false };
         let merkle_root = self.coinbase_merkle_proof.calculate_root(&coinbase_hash);
-        (self.merkle_root == merkle_root) && self.coinbase_merkle_proof.check_coinbase_path()
+        self.merkle_root == merkle_root
     }
 
     /// Returns the block hashing blob for the Monero block.
@@ -164,8 +227,6 @@ impl MoneroPowData {
 
 impl fmt::Debug for MoneroPowData {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        let mut digest = [0u8; 32];
-        self.coinbase_tx_hasher.clone().finalize(&mut digest);
         f.debug_struct("MoneroPowData")
             .field("header", &self.header)
             .field("randomx_key", &self.randomx_key)
@@ -180,7 +241,9 @@ impl fmt::Debug for MoneroPowData {
 
 impl Encodable for MoneroPowData {
     fn encode<S: Write>(&self, s: &mut S) -> io::Result<usize> {
-        let mut n = 0;
+        // Bounds and canonicality are established at construction/decoding.
+        // Encoding must only fail on the writer: Header::hash relies on this.
+        let mut n = POW_DATA_FORMAT.encode(s)?;
 
         // Monero library encoding doesn't do async, so in order to
         // match our AsyncEncodable implementation, we will write
@@ -199,10 +262,7 @@ impl Encodable for MoneroPowData {
 
         n += self.coinbase_merkle_proof.encode(s)?;
 
-        // This is an incomplete hasher. Dump it from memory
-        // and write it down. We can restore it the same way.
-        let buf = keccak_to_bytes(&self.coinbase_tx_hasher);
-        n += buf.encode(s)?;
+        n += self.coinbase_tx_prefix.as_bytes().encode(s)?;
 
         n += self.coinbase_tx_extra.0.encode(s)?;
         n += self.aux_chain_merkle_proof.encode(s)?;
@@ -215,7 +275,7 @@ impl Encodable for MoneroPowData {
 #[async_trait]
 impl AsyncEncodable for MoneroPowData {
     async fn encode_async<S: AsyncWrite + Unpin + Send>(&self, s: &mut S) -> io::Result<usize> {
-        let mut n = 0;
+        let mut n = POW_DATA_FORMAT.encode_async(s).await?;
 
         // We write to an intermediate buffer since the Monero
         // consensus encoding library doesn't do async writing.
@@ -232,10 +292,10 @@ impl AsyncEncodable for MoneroPowData {
 
         n += self.coinbase_merkle_proof.encode_async(s).await?;
 
-        // This is an incomplete hasher. Dump it from memory
-        // and write it down. We can restore it the same way.
-        let buf = keccak_to_bytes(&self.coinbase_tx_hasher);
-        n += buf.encode_async(s).await?;
+        let prefix = self.coinbase_tx_prefix.as_bytes();
+        n += darkfi_serial::VarInt(prefix.len() as u64).encode_async(s).await?;
+        s.write_all(prefix).await?;
+        n += prefix.len();
 
         n += self.coinbase_tx_extra.0.encode_async(s).await?;
         n += self.aux_chain_merkle_proof.encode_async(s).await?;
@@ -246,25 +306,29 @@ impl AsyncEncodable for MoneroPowData {
 
 impl Decodable for MoneroPowData {
     fn decode<D: Read>(d: &mut D) -> io::Result<Self> {
-        let buf: Vec<u8> = Decodable::decode(d)?;
-        let mut buf = Cursor::new(buf);
-        let header = BlockHeader::consensus_decode(&mut buf)
-            .map_err(|_| Error::other("Invalid XMR header"))?;
+        let marker: [u8; 8] = Decodable::decode(d)?;
+        if marker != POW_DATA_FORMAT {
+            return Err(Error::other("Unsupported Monero PoW format"))
+        }
+        let header = decode_header(&decode_bounded_bytes(d, 0, MAX_MONERO_HEADER_SIZE)?)?;
 
         let randomx_key: FixedByteArray = Decodable::decode(d)?;
         let transaction_count: u16 = Decodable::decode(d)?;
+        if transaction_count == 0 {
+            return Err(Error::other("Empty Monero transaction set"))
+        }
 
-        let buf: Vec<u8> = Decodable::decode(d)?;
-        let mut buf = Cursor::new(buf);
+        let buf = decode_bounded_bytes(d, 32, 32)?;
+        let mut buf = buf.as_slice();
         let merkle_root = monero::Hash::consensus_decode(&mut buf)
             .map_err(|_| Error::other("Invalid XMR hash"))?;
 
         let coinbase_merkle_proof: MerkleProof = Decodable::decode(d)?;
 
-        let buf: Vec<u8> = Decodable::decode(d)?;
-        let coinbase_tx_hasher = keccak_from_bytes(&buf);
+        let coinbase_tx_prefix =
+            CoinbasePrefix::new(decode_bounded_bytes(d, 0, MAX_COINBASE_PREFIX_SIZE)?)?;
 
-        let coinbase_tx_extra: Vec<u8> = Decodable::decode(d)?;
+        let coinbase_tx_extra = decode_bounded_bytes(d, 0, MAX_COINBASE_EXTRA_SIZE)?;
         let coinbase_tx_extra = RawExtraField(coinbase_tx_extra);
         let aux_chain_merkle_proof: MerkleProof = Decodable::decode(d)?;
 
@@ -274,7 +338,7 @@ impl Decodable for MoneroPowData {
             transaction_count,
             merkle_root,
             coinbase_merkle_proof,
-            coinbase_tx_hasher,
+            coinbase_tx_prefix,
             coinbase_tx_extra,
             aux_chain_merkle_proof,
         })
@@ -285,25 +349,30 @@ impl Decodable for MoneroPowData {
 #[async_trait]
 impl AsyncDecodable for MoneroPowData {
     async fn decode_async<D: AsyncRead + Unpin + Send>(d: &mut D) -> io::Result<Self> {
-        let buf: Vec<u8> = AsyncDecodable::decode_async(d).await?;
-        let mut buf = Cursor::new(buf);
-        let header = BlockHeader::consensus_decode(&mut buf)
-            .map_err(|_| Error::other("Invalid XMR header"))?;
+        let marker: [u8; 8] = AsyncDecodable::decode_async(d).await?;
+        if marker != POW_DATA_FORMAT {
+            return Err(Error::other("Unsupported Monero PoW format"))
+        }
+        let header =
+            decode_header(&decode_bounded_bytes_async(d, 0, MAX_MONERO_HEADER_SIZE).await?)?;
 
         let randomx_key: FixedByteArray = AsyncDecodable::decode_async(d).await?;
         let transaction_count: u16 = AsyncDecodable::decode_async(d).await?;
+        if transaction_count == 0 {
+            return Err(Error::other("Empty Monero transaction set"))
+        }
 
-        let buf: Vec<u8> = AsyncDecodable::decode_async(d).await?;
-        let mut buf = Cursor::new(buf);
+        let buf = decode_bounded_bytes_async(d, 32, 32).await?;
+        let mut buf = buf.as_slice();
         let merkle_root = monero::Hash::consensus_decode(&mut buf)
             .map_err(|_| Error::other("Invalid XMR hash"))?;
 
         let coinbase_merkle_proof: MerkleProof = AsyncDecodable::decode_async(d).await?;
 
-        let buf: Vec<u8> = AsyncDecodable::decode_async(d).await?;
-        let coinbase_tx_hasher = keccak_from_bytes(&buf);
+        let coinbase_tx_prefix =
+            CoinbasePrefix::new(decode_bounded_bytes_async(d, 0, MAX_COINBASE_PREFIX_SIZE).await?)?;
 
-        let coinbase_tx_extra: Vec<u8> = AsyncDecodable::decode_async(d).await?;
+        let coinbase_tx_extra = decode_bounded_bytes_async(d, 0, MAX_COINBASE_EXTRA_SIZE).await?;
         let coinbase_tx_extra = RawExtraField(coinbase_tx_extra);
         let aux_chain_merkle_proof: MerkleProof = AsyncDecodable::decode_async(d).await?;
 
@@ -313,16 +382,67 @@ impl AsyncDecodable for MoneroPowData {
             transaction_count,
             merkle_root,
             coinbase_merkle_proof,
-            coinbase_tx_hasher,
+            coinbase_tx_prefix,
             coinbase_tx_extra,
             aux_chain_merkle_proof,
         })
     }
 }
 
+/// Decode framing before allocating. A bounded input slice alone does not
+/// bound a generic vector decoder's allocation from an attacker-chosen length.
+fn decode_bounded_bytes<D: Read>(d: &mut D, min: usize, max: usize) -> io::Result<Vec<u8>> {
+    let size = darkfi_serial::VarInt::decode(d)?.0;
+    let size = bounded_size(size, min, max)?;
+    let mut bytes = vec![0u8; size];
+    d.read_exact(&mut bytes)?;
+    Ok(bytes)
+}
+
+#[cfg(feature = "async-serial")]
+async fn decode_bounded_bytes_async<D: AsyncRead + Unpin + Send>(
+    d: &mut D,
+    min: usize,
+    max: usize,
+) -> io::Result<Vec<u8>> {
+    let size = darkfi_serial::VarInt::decode_async(d).await?.0;
+    let size = bounded_size(size, min, max)?;
+    let mut bytes = vec![0u8; size];
+    d.read_exact(&mut bytes).await?;
+    Ok(bytes)
+}
+
+fn bounded_size(size: u64, min: usize, max: usize) -> io::Result<usize> {
+    let size = usize::try_from(size).map_err(|_| Error::other("Monero field length overflow"))?;
+    if !(min..=max).contains(&size) {
+        return Err(Error::other("Invalid Monero field length"))
+    }
+    Ok(size)
+}
+
+fn decode_header(bytes: &[u8]) -> io::Result<BlockHeader> {
+    // Read scalars with a fixed-work decoder, rather than the dependency's
+    // continuation-byte vector. The remaining fields are fixed-size.
+    let mut reader = bytes;
+    let header = BlockHeader {
+        major_version: read_monero_varint(&mut reader)?,
+        minor_version: read_monero_varint(&mut reader)?,
+        timestamp: read_monero_varint(&mut reader)?,
+        prev_id: monero::Hash::consensus_decode(&mut reader)
+            .map_err(|_| Error::other("Invalid Monero previous hash"))?,
+        nonce: Decodable::decode(&mut reader)?,
+    };
+    let mut canonical = vec![];
+    header.consensus_encode(&mut canonical)?;
+    if !reader.is_empty() || canonical != bytes {
+        return Err(Error::other("Noncanonical Monero header"))
+    }
+    Ok(header)
+}
+
 /// Create a set of ordered transaction hashes from a Monero block
 pub fn create_ordered_tx_hashes_from_block(block: &monero::Block) -> Vec<monero::Hash> {
-    iter::once(block.miner_tx.hash()).chain(block.tx_hashes.clone()).collect()
+    iter::once(block.miner_tx.hash()).chain(block.tx_hashes.iter().copied()).collect()
 }
 
 /// Inserts aux chain merkle root and info into a Monero block
@@ -475,91 +595,3 @@ pub fn extract_aux_merkle_root(extra_field: &RawExtraField) -> Result<Option<mon
         Ok(None)
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use std::str::FromStr;
-
-    // Blob from Monero testnet, height 2912484, mergemined DarkFi.
-    const XMR_BLOCK: &str = "1010f881efca0644a1185eeccb2629b316ec0d41659111299ad1b736a3b0d8eac8bbc6384dc5c84bb6010002a0e2b10101ffe4e1b1010180e0a596bb1103f1d23951bd28ce2bfad791f2350e2ac348e4620e19af3418653a1839cc5c8f2be14a010b204d874ed5087b649c711dd4479434a85dbf7e9bdfae26f5bc785964d4b45c0204751b43e10321082d5f403be836d45d026fbaa2a8e4b4a9d0d821f29d709321f8d764f32d446fa80000";
-    const SEED_HASH: &str = "f1d23951bd28ce2bfad791f2350e2ac348e4620e19af3418653a1839cc5c8f2b";
-
-    // Test that both sync and async serialization formats match.
-    // We do some hacks because Monero lib doesn't do async.
-    #[test]
-    fn test_monero_powdata_serde() {
-        let block = monero_block_deserialize(XMR_BLOCK).unwrap();
-        let seed = FixedByteArray::from_bytes(&hex::decode(SEED_HASH).unwrap()).unwrap();
-
-        // The Merkle proof is fake to keep it simple.
-        let tx_hashes = &[
-            "d96756959949db23764592fea0bfe88c790e1fd131dabb676948b343aa9ecc24",
-            "77d1a87df131c36da4832a7ec382db9b8fe947576a60ec82cc1c66a220f6ee42",
-        ]
-        .iter()
-        .map(|hash| monero::Hash::from_str(hash).unwrap())
-        .collect::<Vec<_>>();
-
-        let aux_chain_merkle_proof = create_merkle_proof(tx_hashes, &tx_hashes[0]).unwrap();
-
-        // Construct PowData
-        let mut powdata = MoneroPowData::new(block, seed, aux_chain_merkle_proof).unwrap();
-
-        let local_ex = smol::LocalExecutor::new();
-
-        let ser_sync = darkfi_serial::serialize(&powdata);
-        let ser_async = smol::future::block_on(
-            local_ex.run(async { darkfi_serial::serialize_async(&powdata).await }),
-        );
-
-        assert_eq!(ser_sync, ser_async);
-
-        let mut de_sync: MoneroPowData = darkfi_serial::deserialize(&ser_async).unwrap();
-        let mut de_async: MoneroPowData = smol::future::block_on(
-            local_ex.run(async { darkfi_serial::deserialize_async(&ser_async).await.unwrap() }),
-        );
-
-        assert_eq!(de_sync.header, powdata.header);
-        assert_eq!(de_sync.randomx_key, powdata.randomx_key);
-        assert_eq!(de_sync.transaction_count, powdata.transaction_count);
-        assert_eq!(de_sync.merkle_root, powdata.merkle_root);
-        assert_eq!(de_sync.coinbase_merkle_proof.branch(), powdata.coinbase_merkle_proof.branch());
-        assert_eq!(de_sync.coinbase_merkle_proof.path(), powdata.coinbase_merkle_proof.path());
-        assert_eq!(de_sync.coinbase_tx_extra, powdata.coinbase_tx_extra);
-        assert_eq!(
-            de_sync.aux_chain_merkle_proof.branch(),
-            powdata.aux_chain_merkle_proof.branch()
-        );
-        assert_eq!(de_sync.aux_chain_merkle_proof.path(), powdata.aux_chain_merkle_proof.path());
-
-        assert_eq!(de_async.header, powdata.header);
-        assert_eq!(de_async.randomx_key, powdata.randomx_key);
-        assert_eq!(de_async.transaction_count, powdata.transaction_count);
-        assert_eq!(de_async.merkle_root, powdata.merkle_root);
-        assert_eq!(de_async.coinbase_merkle_proof.branch(), powdata.coinbase_merkle_proof.branch());
-        assert_eq!(de_async.coinbase_merkle_proof.path(), powdata.coinbase_merkle_proof.path());
-        assert_eq!(de_async.coinbase_tx_extra, powdata.coinbase_tx_extra);
-        assert_eq!(
-            de_async.aux_chain_merkle_proof.branch(),
-            powdata.aux_chain_merkle_proof.branch()
-        );
-        assert_eq!(de_async.aux_chain_merkle_proof.path(), powdata.aux_chain_merkle_proof.path());
-
-        // Keccak state
-        powdata.coinbase_tx_hasher.update(b"hi");
-        let mut powdata_digest = vec![];
-        powdata.coinbase_tx_hasher.finalize(&mut powdata_digest);
-
-        de_sync.coinbase_tx_hasher.update(b"hi");
-        let mut de_sync_digest = vec![];
-        de_sync.coinbase_tx_hasher.finalize(&mut de_sync_digest);
-
-        de_async.coinbase_tx_hasher.update(b"hi");
-        let mut de_async_digest = vec![];
-        de_async.coinbase_tx_hasher.finalize(&mut de_async_digest);
-
-        assert_eq!(de_sync_digest, powdata_digest);
-        assert_eq!(de_async_digest, powdata_digest);
-    }
-}

+ 605 - 0
src/blockchain/monero/tests.rs

@@ -0,0 +1,605 @@
+/* This file is part of DarkFi (https://dark.fi)
+ *
+ * Copyright (C) 2020-2026 Dyne.org foundation
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::ops::Range;
+
+use monero::blockdata::transaction::{KeyImage, TxIn, TxOut, TxOutTarget};
+
+use super::*;
+use crate::blockchain::{header_store::PowData, Header, HeaderHash};
+
+// Blob from Monero testnet, height 2912484, mergemined DarkFi.
+const XMR_BLOCK: &str = "1010f881efca0644a1185eeccb2629b316ec0d41659111299ad1b736a3b0d8eac8bbc6384dc5c84bb6010002a0e2b10101ffe4e1b1010180e0a596bb1103f1d23951bd28ce2bfad791f2350e2ac348e4620e19af3418653a1839cc5c8f2be14a010b204d874ed5087b649c711dd4479434a85dbf7e9bdfae26f5bc785964d4b45c0204751b43e10321082d5f403be836d45d026fbaa2a8e4b4a9d0d821f29d709321f8d764f32d446fa80000";
+const SEED_HASH: &str = "f1d23951bd28ce2bfad791f2350e2ac348e4620e19af3418653a1839cc5c8f2b";
+
+// Hand-written wire vector, including nonempty branches and a nonzero aux path.
+// Its 128-byte E uses CompactSize 80 on the wire, but Monero 8001 when hashed.
+const GOLDEN_WIRE: &str = concat!(
+    "44464d4d00000001",
+    "27010203",
+    "1111111111111111111111111111111111111111111111111111111111111111",
+    "04050607",
+    "02aabb",
+    "0201",
+    "202222222222222222222222222222222222222222222222222222222222222222",
+    "01333333333333333333333333333333333333333333333333333333333333333300000000",
+    "28020001ff00010002",
+    "4444444444444444444444444444444444444444444444444444444444444444",
+    "80",
+    "5555555555555555555555555555555555555555555555555555555555555555",
+    "5555555555555555555555555555555555555555555555555555555555555555",
+    "5555555555555555555555555555555555555555555555555555555555555555",
+    "5555555555555555555555555555555555555555555555555555555555555555",
+    "01666666666666666666666666666666666666666666666666666666666666666601000000",
+);
+
+// Framed field ranges in GOLDEN_WIRE. Every vector in this fixture has a
+// one-byte CompactSize length; these offsets are independent of the decoder.
+const HEADER: Range<usize> = 8..48;
+const KEY: Range<usize> = 48..51;
+const COUNT: Range<usize> = 51..53;
+const ROOT: Range<usize> = 53..86;
+const COINBASE_PROOF: Range<usize> = 86..123;
+const PREFIX: Range<usize> = 123..164;
+const EXTRA: Range<usize> = 164..293;
+const AUX_PROOF: Range<usize> = 293..330;
+
+fn replace_component(wire: &[u8], field: Range<usize>, body: &[u8]) -> Vec<u8> {
+    let mut replaced = wire.to_vec();
+    replaced.splice(field, darkfi_serial::serialize(&body));
+    replaced
+}
+
+fn empty_proof() -> MerkleProof {
+    MerkleProof::try_construct(vec![], 0).unwrap()
+}
+
+fn from_block(block: monero::Block) -> Result<MoneroPowData> {
+    MoneroPowData::new(
+        block,
+        FixedByteArray::from_bytes(&hex::decode(SEED_HASH).unwrap()).unwrap(),
+        empty_proof(),
+    )
+}
+
+fn outputs(tagged: bool, count: usize) -> Vec<TxOut> {
+    let key = [0x42; 32];
+    let target = if tagged {
+        TxOutTarget::ToTaggedKey { key, view_tag: 0xff }
+    } else {
+        TxOutTarget::ToKey { key }
+    };
+    vec![TxOut { amount: monero::VarInt(u64::MAX), target }; count]
+}
+
+fn golden_powdata() -> MoneroPowData {
+    let mut prefix = vec![2, 0, 1, 0xff, 0, 1, 0, 2];
+    prefix.extend([0x44; 32]);
+    MoneroPowData {
+        header: BlockHeader {
+            major_version: monero::VarInt(1),
+            minor_version: monero::VarInt(2),
+            timestamp: monero::VarInt(3),
+            prev_id: monero::Hash::from([0x11; 32]),
+            nonce: 0x07060504,
+        },
+        randomx_key: FixedByteArray::from_bytes(&[0xaa, 0xbb]).unwrap(),
+        transaction_count: 258,
+        merkle_root: monero::Hash::from([0x22; 32]),
+        coinbase_merkle_proof: MerkleProof::try_construct(vec![monero::Hash::from([0x33; 32])], 0)
+            .unwrap(),
+        coinbase_tx_prefix: CoinbasePrefix::new(prefix).unwrap(),
+        coinbase_tx_extra: RawExtraField(vec![0x55; 128]),
+        aux_chain_merkle_proof: MerkleProof::try_construct(vec![monero::Hash::from([0x66; 32])], 1)
+            .unwrap(),
+    }
+}
+
+fn assert_fields(actual: &MoneroPowData, expected: &MoneroPowData) {
+    assert_eq!(actual.header, expected.header);
+    assert_eq!(actual.randomx_key(), expected.randomx_key());
+    assert_eq!(actual.transaction_count(), expected.transaction_count());
+    assert_eq!(actual.merkle_root, expected.merkle_root);
+    assert_eq!(actual.coinbase_merkle_proof.branch(), expected.coinbase_merkle_proof.branch());
+    assert_eq!(actual.coinbase_merkle_proof.path(), expected.coinbase_merkle_proof.path());
+    assert_eq!(actual.coinbase_tx_prefix(), expected.coinbase_tx_prefix());
+    assert_eq!(actual.coinbase_tx_extra(), expected.coinbase_tx_extra());
+    assert_eq!(actual.aux_chain_merkle_proof.branch(), expected.aux_chain_merkle_proof.branch());
+    assert_eq!(actual.aux_chain_merkle_proof.path(), expected.aux_chain_merkle_proof.path());
+}
+
+fn assert_roundtrip(powdata: &MoneroPowData) -> MoneroPowData {
+    let wire = darkfi_serial::serialize(powdata);
+    // Cross-decode each encoder's bytes once; independent hash correctness
+    // belongs in the full-block fixture test rather than every roundtrip.
+    #[cfg(feature = "async-serial")]
+    let decode_wire = smol::future::block_on(async {
+        let async_wire = darkfi_serial::serialize_async(powdata).await;
+        assert_eq!(async_wire, wire);
+        let async_decoded: MoneroPowData = darkfi_serial::deserialize_async(&wire).await.unwrap();
+        assert_fields(&async_decoded, powdata);
+        async_wire
+    });
+    #[cfg(not(feature = "async-serial"))]
+    let decode_wire = &wire;
+    let decoded: MoneroPowData = darkfi_serial::deserialize(&decode_wire).unwrap();
+    assert_fields(&decoded, powdata);
+    assert_eq!(darkfi_serial::serialize(&decoded), wire);
+    decoded
+}
+
+fn assert_rejected(wire: &[u8]) {
+    assert!(darkfi_serial::deserialize::<MoneroPowData>(wire).is_err());
+    #[cfg(feature = "async-serial")]
+    smol::future::block_on(async {
+        assert!(darkfi_serial::deserialize_async::<MoneroPowData>(wire).await.is_err());
+    });
+}
+
+#[test]
+fn fixture_hashes_roots_blob_and_serde_match_monero() {
+    let block = monero_block_deserialize(XMR_BLOCK).unwrap();
+    assert_eq!(monero::consensus::serialize(&block), hex::decode(XMR_BLOCK).unwrap());
+    let powdata = from_block(block.clone()).unwrap();
+    assert_eq!(powdata.coinbase_prefix_hash().unwrap(), block.miner_tx.prefix.hash());
+    assert_eq!(powdata.coinbase_hash().unwrap(), block.miner_tx.hash());
+    assert_eq!(powdata.merkle_root, block.tx_root());
+    assert_eq!(powdata.to_block_hashing_blob(), block.serialize_hashable());
+    assert_eq!(powdata.transaction_count(), 1);
+    assert!(powdata.is_coinbase_valid_merkle_root());
+
+    let mut prefix = powdata.coinbase_tx_prefix().to_vec();
+    prefix.extend(monero::consensus::serialize(powdata.coinbase_tx_extra()));
+    assert_eq!(prefix, monero::consensus::serialize(&block.miner_tx.prefix));
+    assert_eq!(monero::Hash::new(&prefix), powdata.coinbase_prefix_hash().unwrap());
+    prefix.push(0); // Present null RingCT base is outside the prefix hash.
+    assert_eq!(prefix, monero::consensus::serialize(&block.miner_tx));
+    let mut hash_input = block.miner_tx.prefix.hash().to_bytes().to_vec();
+    hash_input.extend(monero::Hash::new([0]).to_bytes());
+    hash_input.extend([0; 32]);
+    assert_eq!(monero::Hash::new(&hash_input), block.miner_tx.hash());
+    for wrong_base in [monero::Hash::null(), monero::Hash::new([])] {
+        hash_input[32..64].copy_from_slice(wrong_base.as_bytes());
+        assert_ne!(monero::Hash::new(&hash_input), powdata.coinbase_hash().unwrap());
+    }
+    let decoded = assert_roundtrip(&powdata);
+    assert_eq!(decoded.coinbase_prefix_hash().unwrap(), block.miner_tx.prefix.hash());
+    assert_eq!(decoded.coinbase_hash().unwrap(), block.miner_tx.hash());
+}
+
+#[test]
+fn fixed_golden_wire_and_distinct_extra_length_encodings() {
+    let powdata = golden_powdata();
+    let wire = hex::decode(GOLDEN_WIRE).unwrap();
+    assert_eq!(darkfi_serial::serialize(&powdata), wire);
+    assert_roundtrip(&powdata);
+
+    let mut hash_input = powdata.coinbase_tx_prefix().to_vec();
+    hash_input.extend([0x80, 0x01]);
+    hash_input.extend([0x55; 128]);
+    assert_eq!(powdata.coinbase_prefix_hash().unwrap(), monero::Hash::new(&hash_input));
+    let wrong_input = [powdata.coinbase_tx_prefix(), &wire[EXTRA]].concat();
+    assert_ne!(powdata.coinbase_prefix_hash().unwrap(), monero::Hash::new(wrong_input));
+    let mut blob = monero::consensus::serialize(&powdata.header);
+    blob.extend([0x22; 32]);
+    blob.extend([0x82, 0x02]); // V(258), not transport u16 0201.
+    assert_eq!(powdata.to_block_hashing_blob(), blob);
+}
+
+#[test]
+fn marker_legacy_shape_every_truncation_and_trailing_bytes() {
+    let wire = hex::decode(GOLDEN_WIRE).unwrap();
+    for end in 0..wire.len() {
+        assert_rejected(&wire[..end]);
+    }
+    for index in 0..POW_DATA_FORMAT.len() {
+        let mut wrong_marker = wire.clone();
+        wrong_marker[index] ^= 1;
+        assert_rejected(&wrong_marker);
+    }
+    assert_rejected(&[wire.as_slice(), &[0]].concat());
+    assert_rejected(&wire[POW_DATA_FORMAT.len()..]);
+    // Old layout: no marker, 217-byte sponge field (200 buffer, two u64s,
+    // delimiter) in place of P. Never restore or execute this historical state.
+    let mut legacy = wire[POW_DATA_FORMAT.len()..PREFIX.start].to_vec();
+    legacy.push(217);
+    legacy.extend([0; 200]);
+    legacy.extend(0u64.to_le_bytes());
+    legacy.extend(136u64.to_le_bytes());
+    legacy.push(1);
+    legacy.extend_from_slice(&wire[EXTRA.start..]);
+    assert_rejected(&legacy);
+    // Merely adding the new marker cannot turn a sponge into a valid prefix.
+    assert_rejected(&[POW_DATA_FORMAT.as_slice(), legacy.as_slice()].concat());
+}
+
+// A bounds failure must occur before even attempting to read its absent body.
+struct NoBodyReader<'a>(&'a [u8]);
+
+impl Read for NoBodyReader<'_> {
+    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+        assert!(!self.0.is_empty(), "decoder attempted to read an oversized component body");
+        Read::read(&mut self.0, buf)
+    }
+}
+
+#[cfg(feature = "async-serial")]
+impl AsyncRead for NoBodyReader<'_> {
+    fn poll_read(
+        mut self: std::pin::Pin<&mut Self>,
+        _: &mut std::task::Context<'_>,
+        buf: &mut [u8],
+    ) -> std::task::Poll<io::Result<usize>> {
+        std::task::Poll::Ready(Read::read(&mut *self, buf))
+    }
+}
+
+fn assert_early_rejection(wire: &[u8]) {
+    let mut reader = NoBodyReader(wire);
+    assert!(MoneroPowData::decode(&mut reader).is_err());
+    assert!(reader.0.is_empty());
+    #[cfg(feature = "async-serial")]
+    smol::future::block_on(async {
+        let mut reader = NoBodyReader(wire);
+        assert!(MoneroPowData::decode_async(&mut reader).await.is_err());
+        assert!(reader.0.is_empty());
+    });
+}
+
+#[test]
+fn vector_bounds_are_checked_before_body_reads_in_both_codecs() {
+    let wire = hex::decode(GOLDEN_WIRE).unwrap();
+    for (field, max) in [
+        (HEADER, MAX_MONERO_HEADER_SIZE),
+        (ROOT, 32),
+        (PREFIX, MAX_COINBASE_PREFIX_SIZE),
+        (EXTRA, MAX_COINBASE_EXTRA_SIZE),
+    ] {
+        for length in [max as u64 + 1, u32::MAX as u64, u64::MAX] {
+            let framing = darkfi_serial::serialize(&darkfi_serial::VarInt(length));
+            assert_early_rejection(&[&wire[..field.start], framing.as_slice()].concat());
+        }
+        for framing in [
+            vec![0xfd, wire[field.start], 0],
+            vec![0xfe, wire[field.start], 0, 0, 0],
+            vec![0xff, wire[field.start], 0, 0, 0, 0, 0, 0, 0],
+        ] {
+            assert_early_rejection(&[&wire[..field.start], framing.as_slice()].concat());
+        }
+    }
+    for (field, bytes) in [
+        (KEY, vec![61]),
+        (COUNT, vec![0, 0]),
+        (ROOT, vec![0]),
+        (ROOT, vec![31]),
+        (COINBASE_PROOF, vec![32]),
+        (AUX_PROOF, vec![255]),
+    ] {
+        assert_early_rejection(&[&wire[..field.start], bytes.as_slice()].concat());
+    }
+}
+
+#[test]
+fn framed_components_require_complete_header_and_prefix() {
+    let wire = hex::decode(GOLDEN_WIRE).unwrap();
+    for field in [HEADER, PREFIX] {
+        let body = &wire[field.start + 1..field.end];
+        // Complete outer framing containing an incomplete component.
+        for length in 0..body.len() {
+            assert_rejected(&replace_component(&wire, field.clone(), &body[..length]));
+        }
+    }
+}
+
+#[test]
+fn malformed_header_and_prefix_inside_valid_framing() {
+    let wire = hex::decode(GOLDEN_WIRE).unwrap();
+    // Prefix-field/scalar combinations are tested at the parser layer. Here
+    // check that both transport decoders actually enforce canonical parsing.
+    for (field, scalars) in [(HEADER, vec![0, 1, 2]), (PREFIX, vec![1])] {
+        let body = &wire[field.start + 1..field.end];
+        for offset in scalars {
+            for replacement in [vec![body[offset] | 0x80, 0], vec![0xff; 10], vec![0x80; 11]] {
+                let mut malformed_body = body.to_vec();
+                malformed_body.splice(offset..offset + 1, replacement);
+                assert_rejected(&replace_component(&wire, field.clone(), &malformed_body));
+            }
+        }
+        for tail in [vec![0], vec![1, 0x42], vec![0x80, 1]] {
+            let malformed_body = [body, tail.as_slice()].concat();
+            assert_rejected(&replace_component(&wire, field.clone(), &malformed_body));
+        }
+    }
+    // Move one byte across the P/E boundary, keeping their concatenation fixed.
+    let prefix = &wire[PREFIX.start + 1..PREFIX.end];
+    let extra = &wire[EXTRA.start + 1..EXTRA.end];
+    let joined = [prefix, extra].concat();
+    for split in [prefix.len() - 1, prefix.len() + 1] {
+        let mut malformed = wire[..PREFIX.start].to_vec();
+        malformed.extend(darkfi_serial::serialize(&&joined[..split]));
+        malformed.extend(darkfi_serial::serialize(&&joined[split..]));
+        malformed.extend_from_slice(&wire[EXTRA.end..]);
+        assert_rejected(&malformed);
+    }
+}
+
+#[test]
+fn constructor_supported_targets_output_extra_and_count_boundaries() {
+    let fixture = monero_block_deserialize(XMR_BLOCK).unwrap();
+    // The tagged maximum is exercised by the combined maximum-component test.
+    for (tagged, count) in [(false, 1), (true, 1), (false, MAX_COINBASE_OUTPUTS)] {
+        let mut block = fixture.clone();
+        block.miner_tx.prefix.outputs = outputs(tagged, count);
+        let powdata = from_block(block.clone()).unwrap();
+        assert_eq!(powdata.coinbase_hash().unwrap(), block.miner_tx.hash());
+        assert!(powdata.is_coinbase_valid_merkle_root());
+    }
+    for count in [0, MAX_COINBASE_OUTPUTS + 1] {
+        let mut block = fixture.clone();
+        block.miner_tx.prefix.outputs = vec![fixture.miner_tx.prefix.outputs[0].clone(); count];
+        assert!(from_block(block).is_err());
+    }
+    for length in [0, 127, 128, 252, 253, 65535, MAX_COINBASE_EXTRA_SIZE] {
+        let mut block = fixture.clone();
+        block.miner_tx.prefix.extra = RawExtraField(vec![0x42; length]);
+        let powdata = from_block(block.clone()).unwrap();
+        assert_eq!(powdata.coinbase_prefix_hash().unwrap(), block.miner_tx.prefix.hash());
+        assert_roundtrip(&powdata);
+    }
+    let mut block = fixture.clone();
+    block.miner_tx.prefix.extra = RawExtraField(vec![0; MAX_COINBASE_EXTRA_SIZE + 1]);
+    assert!(from_block(block).is_err());
+    for count in [1, 128, u16::MAX as usize] {
+        let mut block = fixture.clone();
+        // Distinct hashes make the independent root/blob comparisons detect
+        // reordering, which a vector of identical hashes would conceal.
+        block.tx_hashes =
+            (1..count).map(|index| monero::Hash::new((index as u64).to_le_bytes())).collect();
+        let powdata = from_block(block.clone()).unwrap();
+        assert_eq!(powdata.transaction_count() as usize, count);
+        assert_eq!(powdata.merkle_root, block.tx_root());
+        assert_eq!(powdata.to_block_hashing_blob(), block.serialize_hashable());
+        assert!(powdata.is_coinbase_valid_merkle_root());
+        assert_roundtrip(&powdata);
+    }
+    let mut block = fixture;
+    block.tx_hashes = vec![monero::Hash::null(); u16::MAX as usize];
+    assert!(from_block(block).is_err());
+}
+
+#[test]
+fn constructor_rejects_unsupported_transactions_before_hashing() {
+    let fixture = monero_block_deserialize(XMR_BLOCK).unwrap();
+    for version in [0, 1, 3, u64::MAX] {
+        let mut block = fixture.clone();
+        block.miner_tx.prefix.version = monero::VarInt(version);
+        assert!(from_block(block).is_err());
+    }
+    for count in [0, 2] {
+        let mut block = fixture.clone();
+        block.miner_tx.prefix.inputs = vec![fixture.miner_tx.prefix.inputs[0].clone(); count];
+        assert!(from_block(block).is_err());
+    }
+    let mut block = fixture.clone();
+    block.miner_tx.prefix.inputs = vec![TxIn::ToKey {
+        amount: monero::VarInt(0),
+        key_offsets: vec![],
+        k_image: KeyImage { image: monero::Hash::null() },
+    }];
+    assert!(from_block(block).is_err());
+    let mut block = fixture.clone();
+    block.miner_tx.signatures.push(vec![]);
+    assert!(from_block(block).is_err());
+    let mut block = fixture.clone();
+    block.miner_tx.rct_signatures.sig = None;
+    assert_ne!(block.miner_tx.hash(), fixture.miner_tx.hash());
+    assert!(from_block(block).is_err());
+    for rct_type in [
+        RctType::Full,
+        RctType::Simple,
+        RctType::Bulletproof,
+        RctType::Bulletproof2,
+        RctType::Clsag,
+        RctType::BulletproofPlus,
+    ] {
+        let mut block = fixture.clone();
+        block.miner_tx.rct_signatures.sig.as_mut().unwrap().rct_type = rct_type;
+        assert!(from_block(block).is_err());
+    }
+    let mut block = fixture;
+    block.miner_tx.rct_signatures.p = Some(monero::util::ringct::RctSigPrunable {
+        range_sigs: vec![],
+        bulletproofs: vec![],
+        bulletproofplus: vec![],
+        MGs: vec![],
+        Clsags: vec![],
+        pseudo_outs: vec![],
+    });
+    assert!(from_block(block).is_err());
+}
+
+#[test]
+fn largest_supported_components_roundtrip_without_extra_inspection() {
+    let mut block = monero_block_deserialize(XMR_BLOCK).unwrap();
+    block.header.major_version = monero::VarInt(u64::MAX);
+    block.header.minor_version = monero::VarInt(u64::MAX);
+    block.header.timestamp = monero::VarInt(u64::MAX);
+    block.miner_tx.prefix.unlock_time = monero::VarInt(u64::MAX);
+    block.miner_tx.prefix.inputs = vec![TxIn::Gen { height: monero::VarInt(u64::MAX) }];
+    block.miner_tx.prefix.outputs = outputs(true, MAX_COINBASE_OUTPUTS);
+    // Opaque bytes only: never invoke the existing partial extra parser here.
+    block.miner_tx.prefix.extra = RawExtraField(vec![0xff; MAX_COINBASE_EXTRA_SIZE]);
+    let mut powdata = MoneroPowData::new(
+        block.clone(),
+        FixedByteArray::from_bytes(&[0x42; 60]).unwrap(),
+        MerkleProof::try_construct(vec![monero::Hash::from([0x33; 32]); 31], u32::MAX).unwrap(),
+    )
+    .unwrap();
+    assert_eq!(powdata.coinbase_hash().unwrap(), block.miner_tx.hash());
+    assert_eq!(powdata.coinbase_tx_prefix().len(), 44025);
+    assert_eq!(monero::consensus::serialize(&powdata.header).len(), 66);
+    assert_eq!(powdata.randomx_key().len(), 60);
+    powdata.coinbase_merkle_proof =
+        MerkleProof::try_construct(vec![monero::Hash::from([0x22; 32]); 31], 0).unwrap();
+    powdata.merkle_root = powdata.coinbase_merkle_proof.calculate_root(&block.miner_tx.hash());
+    assert!(powdata.is_coinbase_valid_merkle_root());
+    let wire = darkfi_serial::serialize(&powdata);
+    // Actual grammar maxima are tighter than the conservative 133309-byte cap.
+    assert_eq!(wire.len(), 8 + 67 + 61 + 2 + 33 + 2 * 997 + 3 + 44025 + 5 + 65536);
+    assert_roundtrip(&powdata);
+}
+
+#[test]
+fn complete_raw_extra_including_unknown_tail_is_hashed() {
+    let mut block = monero_block_deserialize(XMR_BLOCK).unwrap();
+    let original = from_block(block.clone()).unwrap();
+    // An unknown subfield cannot be discarded before hashing. No tag parser
+    // is needed to establish equality with the complete Monero transaction.
+    block.miner_tx.prefix.extra.0.extend([0xff, 0x42]);
+    let powdata = from_block(block.clone()).unwrap();
+    assert_eq!(powdata.coinbase_tx_prefix(), original.coinbase_tx_prefix());
+    assert_eq!(powdata.coinbase_prefix_hash().unwrap(), block.miner_tx.prefix.hash());
+    assert_eq!(powdata.coinbase_hash().unwrap(), block.miner_tx.hash());
+    assert_ne!(powdata.coinbase_hash().unwrap(), original.coinbase_hash().unwrap());
+    assert_roundtrip(&powdata);
+}
+
+fn bound_header() -> Header {
+    let mut header = Header::new(HeaderHash::new([0x42; 32]), 1, 7, 1_700_000_000u64.into());
+    let mut block = monero_block_deserialize(XMR_BLOCK).unwrap();
+    block.miner_tx.prefix.extra = ExtraField(vec![SubField::MergeMining(
+        monero::VarInt(0),
+        monero::Hash::from(header.template_hash().inner()),
+    )])
+    .into();
+    header.pow_data = PowData::Monero(from_block(block).unwrap());
+    assert!(header.validate_powdata());
+    header
+}
+
+#[test]
+fn template_extra_and_valid_prefix_cannot_rebind_existing_work() {
+    let honest = bound_header();
+    let PowData::Monero(original) = &honest.pow_data else { unreachable!() };
+    let decoded: Header = darkfi_serial::deserialize(&darkfi_serial::serialize(&honest)).unwrap();
+    assert!(decoded.validate_powdata());
+    assert_eq!(decoded.hash(), honest.hash());
+    #[cfg(feature = "async-serial")]
+    smol::future::block_on(async {
+        let wire = darkfi_serial::serialize_async(&honest).await;
+        assert_eq!(wire, darkfi_serial::serialize(&honest));
+        let decoded: Header = darkfi_serial::deserialize_async(&wire).await.unwrap();
+        assert!(decoded.validate_powdata());
+        assert_eq!(decoded.hash(), honest.hash());
+    });
+
+    let mut changed = honest.clone();
+    changed.nonce += 1;
+    assert_ne!(changed.template_hash(), honest.template_hash());
+    assert!(!changed.validate_powdata());
+    let PowData::Monero(unchanged) = &changed.pow_data else { unreachable!() };
+    assert!(unchanged.is_coinbase_valid_merkle_root());
+
+    let replacement: RawExtraField = ExtraField(vec![SubField::MergeMining(
+        monero::VarInt(0),
+        monero::Hash::from(changed.template_hash().inner()),
+    )])
+    .into();
+    assert!(ExtraField::try_parse(&replacement).is_ok());
+    let mut rebound = original.clone();
+    rebound.coinbase_tx_extra = replacement;
+    assert_eq!(
+        rebound
+            .aux_chain_merkle_proof
+            .calculate_root(&monero::Hash::from(changed.template_hash().inner())),
+        extract_aux_merkle_root(rebound.coinbase_tx_extra()).unwrap().unwrap()
+    );
+    assert_ne!(rebound.coinbase_hash().unwrap(), original.coinbase_hash().unwrap());
+    assert!(!rebound.is_coinbase_valid_merkle_root());
+    assert_eq!(rebound.to_block_hashing_blob(), original.to_block_hashing_blob());
+    assert_eq!(rebound.randomx_key(), original.randomx_key());
+    changed.pow_data = PowData::Monero(assert_roundtrip(&rebound));
+    assert!(!changed.validate_powdata());
+
+    let mut altered_block = monero_block_deserialize(XMR_BLOCK).unwrap();
+    altered_block.miner_tx.prefix.unlock_time.0 += 1;
+    let altered = from_block(altered_block).unwrap();
+    let mut rebound = original.clone();
+    rebound.coinbase_tx_prefix =
+        CoinbasePrefix::new(altered.coinbase_tx_prefix().to_vec()).unwrap();
+    assert_ne!(rebound.coinbase_tx_prefix(), original.coinbase_tx_prefix());
+    assert_ne!(rebound.coinbase_hash().unwrap(), original.coinbase_hash().unwrap());
+    assert!(!rebound.is_coinbase_valid_merkle_root());
+    assert_eq!(rebound.to_block_hashing_blob(), original.to_block_hashing_blob());
+    let mut changed = honest.clone();
+    changed.pow_data = PowData::Monero(assert_roundtrip(&rebound));
+    assert!(!changed.validate_powdata());
+
+    let mut non_coinbase_path = original.clone();
+    non_coinbase_path.coinbase_merkle_proof = MerkleProof::try_construct(vec![], 1).unwrap();
+    assert_eq!(non_coinbase_path.coinbase_hash().unwrap(), non_coinbase_path.merkle_root);
+    assert!(!non_coinbase_path.is_coinbase_valid_merkle_root());
+}
+
+#[cfg(feature = "validator")]
+#[test]
+fn consistent_commitments_still_require_randomx_below_local_target() -> Result<()> {
+    use darkfi_sdk::num_traits::{One, Zero};
+    use kvdb_overlay::Database;
+    use num_bigint::BigUint;
+
+    use crate::{
+        blockchain::{BlockInfo, Blockchain},
+        validator::pow::PoWModule,
+    };
+
+    let header = bound_header();
+    let PowData::Monero(powdata) = &header.pow_data else { unreachable!() };
+    let (database, _folder) = Database::open_temp()?;
+    let blockchain = Blockchain::new(&database)?;
+    let mut genesis = BlockInfo::default();
+    genesis.header.timestamp = 0u64.into();
+    blockchain.add_block(&genesis)?;
+    let mut module = PoWModule::new(blockchain, 120, Some(BigUint::one()), None)?;
+    // Fixed difficulty only applies after two local timestamps exist.
+    module.append(&header, &BigUint::one())?;
+    module.append(&header, &BigUint::one())?;
+
+    let vm = module.monero_rx_factory.create(powdata.randomx_key())?;
+    let digest_bytes = vm.calculate_hash(&powdata.to_block_hashing_blob())?;
+    assert_eq!(digest_bytes.len(), 32);
+    let digest = BigUint::from_bytes_le(&digest_bytes);
+    assert!(!digest.is_zero());
+    assert_eq!(module.calculate_hash(&header)?, digest);
+    assert_eq!(module.verify_block_target(&header, &digest)?, digest);
+    assert!(matches!(
+        module.verify_block_target(&header, &(&digest - BigUint::one())),
+        Err(crate::Error::PoWInvalidOutHash)
+    ));
+    let maximum = BigUint::from_bytes_le(&[0xff; 32]);
+    assert_eq!(module.next_mine_target()?, maximum);
+    module.verify_block_hash(&header)?;
+
+    let strict_difficulty = &maximum / &digest + BigUint::one();
+    module.fixed_difficulty = Some(strict_difficulty.clone());
+    assert_eq!(module.next_difficulty()?, strict_difficulty);
+    assert!(module.next_mine_target()? < digest);
+    assert!(header.validate_powdata());
+    assert!(matches!(module.verify_block_hash(&header), Err(crate::Error::PoWInvalidOutHash)));
+    Ok(())
+}