kcode-k1-transaction 0.2.1

Canonical K1 transaction parsing and construction
Documentation
use kcode_k1_transaction_store::TxId;

pub const SUBSYSTEM_BYTES: usize = 20;
pub const PUBLIC_KEY_BYTES: usize = 32;
pub const SIGNATURE_BYTES: usize = 64;
pub const MIN_TRANSACTION_BYTES: usize = 136;
pub const GENESIS_PARENT: TxId = TxId::from_bytes([0xff; 12]);
pub const REGISTER_AT_TIP: TxId = TxId::from_bytes([
    0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe,
]);

#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct SubsystemId {
    bytes: [u8; SUBSYSTEM_BYTES],
}

impl SubsystemId {
    pub fn from_bytes(bytes: [u8; SUBSYSTEM_BYTES]) -> Result<Self, String> {
        let logical_len = bytes
            .iter()
            .position(|byte| *byte == 0)
            .unwrap_or(SUBSYSTEM_BYTES);
        if logical_len == 0 {
            return Err("subsystem ID must not be empty".to_owned());
        }
        if bytes[logical_len..].iter().any(|byte| *byte != 0) {
            return Err("subsystem ID has nonzero bytes after padding".to_owned());
        }
        std::str::from_utf8(&bytes[..logical_len]).map_err(|error| error.to_string())?;
        Ok(Self { bytes })
    }

    #[allow(clippy::should_implement_trait)]
    pub fn from_str(value: &str) -> Result<Self, String> {
        if value.is_empty() {
            return Err("subsystem ID must not be empty".to_owned());
        }
        if value.len() > SUBSYSTEM_BYTES {
            return Err("subsystem ID must contain at most 20 bytes".to_owned());
        }
        if value.as_bytes().contains(&0) {
            return Err("subsystem ID must not contain NUL".to_owned());
        }
        let mut bytes = [0; SUBSYSTEM_BYTES];
        bytes[..value.len()].copy_from_slice(value.as_bytes());
        Self::from_bytes(bytes)
    }

    pub const fn as_bytes(&self) -> &[u8; SUBSYSTEM_BYTES] {
        &self.bytes
    }

    pub fn as_str(&self) -> &str {
        let logical_len = self
            .bytes
            .iter()
            .position(|byte| *byte == 0)
            .unwrap_or(SUBSYSTEM_BYTES);
        std::str::from_utf8(&self.bytes[..logical_len])
            .expect("SubsystemId always contains canonical UTF-8")
    }
}

pub struct Transaction<'a> {
    bytes: &'a [u8],
    subsystem: SubsystemId,
}

impl<'a> Transaction<'a> {
    pub fn parse(bytes: &'a [u8]) -> Result<Self, String> {
        if bytes.len() < MIN_TRANSACTION_BYTES {
            return Err(format!(
                "transaction must contain at least {MIN_TRANSACTION_BYTES} bytes"
            ));
        }

        let subsystem_bytes = bytes[52..72]
            .try_into()
            .map_err(|_| "invalid subsystem ID".to_owned())?;
        let subsystem = SubsystemId::from_bytes(subsystem_bytes)?;
        Ok(Self { bytes, subsystem })
    }

    pub fn parent(&self) -> TxId {
        TxId::from_bytes(self.bytes[..12].try_into().expect("fixed parent range"))
    }

    pub fn timestamp(&self) -> u64 {
        u64::from_le_bytes(
            self.bytes[12..20]
                .try_into()
                .expect("fixed timestamp range"),
        )
    }

    pub fn creator(&self) -> &[u8; PUBLIC_KEY_BYTES] {
        self.bytes[20..52].try_into().expect("fixed creator range")
    }

    pub fn subsystem(&self) -> SubsystemId {
        self.subsystem
    }

    pub fn payload(&self) -> &'a [u8] {
        &self.bytes[72..self.bytes.len() - SIGNATURE_BYTES]
    }

    pub fn signature(&self) -> &'a [u8; SIGNATURE_BYTES] {
        self.bytes[self.bytes.len() - SIGNATURE_BYTES..]
            .try_into()
            .expect("fixed signature range")
    }

    pub fn signing_bytes(&self) -> &'a [u8] {
        &self.bytes[..self.bytes.len() - SIGNATURE_BYTES]
    }
}

pub fn build_signed_transaction<F>(
    parent: TxId,
    timestamp: u64,
    creator: [u8; PUBLIC_KEY_BYTES],
    subsystem: SubsystemId,
    payload: &[u8],
    signer: F,
) -> Result<Vec<u8>, String>
where
    F: FnOnce(&[u8]) -> Result<[u8; SIGNATURE_BYTES], String>,
{
    let final_len = MIN_TRANSACTION_BYTES
        .checked_add(payload.len())
        .ok_or_else(|| "transaction length exceeds usize".to_owned())?;
    let mut bytes = Vec::with_capacity(final_len);

    bytes.extend_from_slice(parent.as_bytes());
    bytes.extend_from_slice(&timestamp.to_le_bytes());
    bytes.extend_from_slice(&creator);
    bytes.extend_from_slice(subsystem.as_bytes());
    bytes.extend_from_slice(payload);

    let signature = signer(&bytes)?;
    bytes.extend_from_slice(&signature);

    debug_assert_eq!(bytes.len(), final_len);
    debug_assert_eq!(bytes.capacity(), final_len);
    Ok(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::Cell;

    fn transaction(payload: &[u8], subsystem: [u8; SUBSYSTEM_BYTES]) -> Vec<u8> {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(&[1; 12]);
        bytes.extend_from_slice(&0x0102_0304_0506_0708_u64.to_le_bytes());
        bytes.extend_from_slice(&[2; PUBLIC_KEY_BYTES]);
        bytes.extend_from_slice(&subsystem);
        bytes.extend_from_slice(payload);
        bytes.extend_from_slice(&[3; SIGNATURE_BYTES]);
        bytes
    }

    #[test]
    fn parses_exact_fields_and_empty_payload() {
        let subsystem = *b"12345678901234567890";
        let bytes = transaction(&[], subsystem);
        let parsed = Transaction::parse(&bytes).unwrap();

        assert_eq!(parsed.parent().into_bytes(), [1; 12]);
        assert_eq!(parsed.timestamp(), 0x0102_0304_0506_0708);
        assert_eq!(parsed.creator(), &[2; PUBLIC_KEY_BYTES]);
        assert_eq!(parsed.subsystem().as_bytes(), &subsystem);
        assert_eq!(parsed.subsystem().as_str(), "12345678901234567890");
        assert_eq!(parsed.payload(), &[]);
        assert_eq!(parsed.signature(), &[3; SIGNATURE_BYTES]);
        assert_eq!(parsed.signing_bytes(), &bytes[..72]);
        assert_eq!(bytes.len(), MIN_TRANSACTION_BYTES);
    }

    #[test]
    fn subsystem_logical_lengths_and_padding() {
        for value in ["a", "1234567890123456789", "12345678901234567890"] {
            let subsystem = SubsystemId::from_str(value).unwrap();
            assert_eq!(subsystem.as_str(), value);
            assert_eq!(&subsystem.as_bytes()[..value.len()], value.as_bytes());
            assert!(
                subsystem.as_bytes()[value.len()..]
                    .iter()
                    .all(|byte| *byte == 0)
            );
            assert_eq!(
                SubsystemId::from_bytes(*subsystem.as_bytes()).unwrap(),
                subsystem
            );
        }
    }

    #[test]
    fn subsystem_multibyte_boundaries() {
        let nineteen = "éééééééééa";
        let twenty = "éééééééééé";
        assert_eq!(nineteen.len(), 19);
        assert_eq!(twenty.len(), 20);
        assert_eq!(SubsystemId::from_str(nineteen).unwrap().as_str(), nineteen);
        assert_eq!(SubsystemId::from_str(twenty).unwrap().as_str(), twenty);
        assert!(SubsystemId::from_str("ééééééééééa").is_err());
    }

    #[test]
    fn rejects_noncanonical_subsystem_ids() {
        assert!(SubsystemId::from_str("").is_err());
        assert!(SubsystemId::from_str("123456789012345678901").is_err());
        assert!(SubsystemId::from_str("a\0b").is_err());
        assert!(SubsystemId::from_bytes([0; SUBSYSTEM_BYTES]).is_err());

        let mut embedded = [0; SUBSYSTEM_BYTES];
        embedded[..3].copy_from_slice(b"a\0b");
        assert!(SubsystemId::from_bytes(embedded).is_err());

        let mut invalid_utf8 = [0; SUBSYSTEM_BYTES];
        invalid_utf8[0] = 0xff;
        assert!(SubsystemId::from_bytes(invalid_utf8).is_err());
    }

    #[test]
    fn builds_and_parses_short_subsystem_transaction() {
        let parent = TxId::from_bytes([9; 12]);
        let timestamp = 0x0102_0304_0506_0708;
        let creator = [7; PUBLIC_KEY_BYTES];
        let subsystem = SubsystemId::from_str("k1-groups-subsystem").unwrap();
        let payload = b"payload";
        let calls = Cell::new(0);

        let bytes =
            build_signed_transaction(parent, timestamp, creator, subsystem, payload, |prefix| {
                calls.set(calls.get() + 1);
                assert_eq!(&prefix[52..72], subsystem.as_bytes());
                Ok([5; SIGNATURE_BYTES])
            })
            .unwrap();

        assert_eq!(calls.get(), 1);
        let parsed = Transaction::parse(&bytes).unwrap();
        assert_eq!(parsed.parent(), parent);
        assert_eq!(parsed.timestamp(), timestamp);
        assert_eq!(parsed.creator(), &creator);
        assert_eq!(parsed.subsystem(), subsystem);
        assert_eq!(parsed.subsystem().as_str(), "k1-groups-subsystem");
        assert_eq!(parsed.payload(), payload);
        assert_eq!(parsed.signature(), &[5; SIGNATURE_BYTES]);
    }

    #[test]
    fn existing_twenty_byte_wire_bytes_are_unchanged() {
        let subsystem = SubsystemId::from_str("abcdefghijklmnopqrst").unwrap();
        assert_eq!(subsystem.as_bytes(), b"abcdefghijklmnopqrst");
        let bytes = build_signed_transaction(
            GENESIS_PARENT,
            0,
            [0; PUBLIC_KEY_BYTES],
            subsystem,
            &[],
            |_| Ok([0; SIGNATURE_BYTES]),
        )
        .unwrap();
        assert_eq!(&bytes[52..72], b"abcdefghijklmnopqrst");
        assert_eq!(bytes.len(), MIN_TRANSACTION_BYTES);
    }

    #[test]
    fn preserves_payload_signature_and_signing_prefix() {
        let bytes = transaction(b"payload", *b"abcdefghijklmnopqrst");
        let parsed = Transaction::parse(&bytes).unwrap();

        assert_eq!(parsed.payload(), b"payload");
        assert_eq!(parsed.signature(), &[3; SIGNATURE_BYTES]);
        assert_eq!(
            parsed.signing_bytes(),
            &bytes[..bytes.len() - SIGNATURE_BYTES]
        );
    }

    #[test]
    fn builds_empty_payload_transaction() {
        let subsystem = SubsystemId::from_bytes(*b"12345678901234567890").unwrap();
        let bytes = build_signed_transaction(
            GENESIS_PARENT,
            0,
            [0; PUBLIC_KEY_BYTES],
            subsystem,
            &[],
            |_| Ok([0; SIGNATURE_BYTES]),
        )
        .unwrap();

        assert_eq!(bytes.len(), MIN_TRANSACTION_BYTES);
        assert!(Transaction::parse(&bytes).unwrap().payload().is_empty());
    }

    #[test]
    fn propagates_signer_error_unchanged() {
        let subsystem = SubsystemId::from_str("short").unwrap();
        let calls = Cell::new(0);

        let result = build_signed_transaction(
            GENESIS_PARENT,
            0,
            [0; PUBLIC_KEY_BYTES],
            subsystem,
            b"payload",
            |_| {
                calls.set(calls.get() + 1);
                Err("signing failed exactly".to_owned())
            },
        );

        assert_eq!(calls.get(), 1);
        assert_eq!(result.unwrap_err(), "signing failed exactly");
    }

    #[test]
    fn rejects_short_or_noncanonical_transactions() {
        assert!(Transaction::parse(&[0; MIN_TRANSACTION_BYTES - 1]).is_err());
        assert!(Transaction::parse(&transaction(&[], [0xff; SUBSYSTEM_BYTES])).is_err());

        let mut embedded = [0; SUBSYSTEM_BYTES];
        embedded[..3].copy_from_slice(b"a\0b");
        assert!(Transaction::parse(&transaction(&[], embedded)).is_err());
    }

    #[test]
    fn exposes_registration_sentinels() {
        assert_eq!(GENESIS_PARENT.into_bytes(), [0xff; 12]);
        assert_eq!(
            REGISTER_AT_TIP.into_bytes(),
            [
                0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe
            ]
        );
        assert_ne!(REGISTER_AT_TIP, GENESIS_PARENT);
    }
}