//! Ethereum-compatible representations and commitments for every.channel core types. use alloy_primitives::{keccak256, B256}; use alloy_sol_types::{eip712_domain, sol, Eip712Domain, SolStruct, SolValue}; use ec_core::{ BroadcastId, ChainCommitment, ChunkId, Manifest, ManifestBody, ManifestSignature, ManifestVariant, SourceId, StreamControlAnnouncement, StreamDescriptor, StreamKey, StreamMetadata, StreamTransportDescriptor, }; use std::fmt; pub const ETHEREUM_CHAIN: &str = "ethereum"; pub const SCHEME_STREAM_ID_KECCAK: &str = "stream-id-keccak256-v1"; pub const SCHEME_STREAM_DESCRIPTOR_ABI: &str = "stream-descriptor-abi-keccak256-v1"; pub const SCHEME_CONTROL_ANNOUNCEMENT_ABI: &str = "control-announcement-abi-keccak256-v1"; pub const SCHEME_MANIFEST_DATA_ROOT: &str = "manifest-data-merkle-keccak256-v1"; pub const SCHEME_MANIFEST_BODY_ABI: &str = "manifest-body-abi-keccak256-v1"; pub const SCHEME_MANIFEST_ENVELOPE_ABI: &str = "manifest-envelope-abi-keccak256-v1"; pub const ETH_MANIFEST_SIG_ALG: &str = "secp256k1-eip712-manifest-body-v1"; pub const ZERO_B256_HEX: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; sol! { struct EthStreamMetadata { string key; string value; } struct EthBroadcastId { string standard; bool hasTransportStreamId; uint16 transportStreamId; bool hasProgramNumber; uint16 programNumber; string virtualChannel; string callsign; string region; string frequency; } struct EthSourceId { string kind; string deviceId; string channel; } struct EthStreamKey { uint16 version; bool hasBroadcast; EthBroadcastId broadcast; bool hasSource; EthSourceId source; string profile; string variant; } struct EthStreamDescriptor { string id; string title; string number; string source; EthStreamMetadata[] metadata; } struct EthStreamTransportDescriptor { uint8 kind; string url; string endpoint; string broadcastName; string trackName; } struct EthStreamControlAnnouncement { EthStreamDescriptor stream; EthStreamTransportDescriptor[] transports; uint64 updatedUnixMs; uint64 ttlMs; } struct EthChunkId { string streamId; string epochId; uint64 chunkIndex; bytes32 chunkHash; } struct EthManifestVariant { string variantId; string streamId; uint64 chunkStartIndex; uint64 totalChunks; bytes32 merkleRoot; bytes32[] chunkHashes; EthStreamMetadata[] metadata; } struct EthManifestBody { string streamId; string epochId; uint64 chunkDurationMs; uint64 totalChunks; uint64 chunkStartIndex; string encoderProfileId; bytes32 merkleRoot; uint64 createdUnixMs; EthStreamMetadata[] metadata; bytes32[] chunkHashes; EthManifestVariant[] variants; } struct EthManifestSignature { string signerId; string alg; bytes signature; } struct EthManifest { EthManifestBody body; bytes32 manifestId; EthManifestSignature[] signatures; } struct EthObservationHeader { bytes32 streamHash; bytes32 epochHash; bytes32 parentObservationHash; bytes32 dataRoot; bytes32 locatorHash; uint64 observedUnixMs; uint64 sequence; } } #[derive(Debug, Clone)] pub enum EthCommitmentError { Empty, InvalidHex(String), } impl fmt::Display for EthCommitmentError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { EthCommitmentError::Empty => write!(f, "no hashes supplied"), EthCommitmentError::InvalidHex(value) => { write!(f, "invalid 32-byte hex value: {value}") } } } } impl std::error::Error for EthCommitmentError {} fn commitment(scheme: &str, digest: B256) -> ChainCommitment { ChainCommitment { chain: ETHEREUM_CHAIN.to_string(), scheme: scheme.to_string(), digest: b256_hex(digest), } } fn abi_commitment(scheme: &str, value: &T) -> ChainCommitment { commitment(scheme, keccak256(value.abi_encode())) } pub fn b256_hex(value: B256) -> String { format!("0x{}", hex::encode(value)) } pub fn keccak256_bytes_hex(value: &[u8]) -> String { b256_hex(keccak256(value)) } pub fn parse_b256(value: &str) -> Result { let trimmed = value.trim().strip_prefix("0x").unwrap_or(value.trim()); let bytes = hex::decode(trimmed).map_err(|_| EthCommitmentError::InvalidHex(value.to_string()))?; if bytes.len() != 32 { return Err(EthCommitmentError::InvalidHex(value.to_string())); } let mut out = [0u8; 32]; out.copy_from_slice(&bytes); Ok(B256::from(out)) } fn parse_bytes(value: &str) -> Vec { let trimmed = value.trim().strip_prefix("0x").unwrap_or(value.trim()); hex::decode(trimmed).unwrap_or_default() } pub fn manifest_eip712_domain() -> Eip712Domain { eip712_domain! { name: "every.channel", version: "1", } } fn eth_metadata(items: &[StreamMetadata]) -> Vec { items .iter() .map(|item| EthStreamMetadata { key: item.key.clone(), value: item.value.clone(), }) .collect() } pub fn eth_broadcast_id(value: &BroadcastId) -> EthBroadcastId { EthBroadcastId { standard: value.standard.clone(), hasTransportStreamId: value.transport_stream_id.is_some(), transportStreamId: value.transport_stream_id.unwrap_or_default(), hasProgramNumber: value.program_number.is_some(), programNumber: value.program_number.unwrap_or_default(), virtualChannel: value.virtual_channel.clone().unwrap_or_default(), callsign: value.callsign.clone().unwrap_or_default(), region: value.region.clone().unwrap_or_default(), frequency: value.frequency.clone().unwrap_or_default(), } } pub fn eth_source_id(value: &SourceId) -> EthSourceId { EthSourceId { kind: value.kind.clone(), deviceId: value.device_id.clone().unwrap_or_default(), channel: value.channel.clone().unwrap_or_default(), } } pub fn eth_stream_key(value: &StreamKey) -> EthStreamKey { EthStreamKey { version: value.version, hasBroadcast: value.broadcast.is_some(), broadcast: value .broadcast .as_ref() .map(eth_broadcast_id) .unwrap_or_else(|| EthBroadcastId { standard: String::new(), hasTransportStreamId: false, transportStreamId: 0, hasProgramNumber: false, programNumber: 0, virtualChannel: String::new(), callsign: String::new(), region: String::new(), frequency: String::new(), }), hasSource: value.source.is_some(), source: value .source .as_ref() .map(eth_source_id) .unwrap_or_else(|| EthSourceId { kind: String::new(), deviceId: String::new(), channel: String::new(), }), profile: value.profile.clone().unwrap_or_default(), variant: value.variant.clone().unwrap_or_default(), } } pub fn eth_stream_descriptor(value: &StreamDescriptor) -> EthStreamDescriptor { EthStreamDescriptor { id: value.id.0.clone(), title: value.title.clone(), number: value.number.clone().unwrap_or_default(), source: value.source.clone(), metadata: eth_metadata(&value.metadata), } } pub fn eth_stream_transport_descriptor( value: &StreamTransportDescriptor, ) -> EthStreamTransportDescriptor { match value { StreamTransportDescriptor::RelayMoq { url, broadcast_name, track_name, } => EthStreamTransportDescriptor { kind: 0, url: url.clone(), endpoint: String::new(), broadcastName: broadcast_name.clone(), trackName: track_name.clone(), }, StreamTransportDescriptor::IrohDirect { endpoint, broadcast_name, track_name, } => EthStreamTransportDescriptor { kind: 1, url: String::new(), endpoint: endpoint.clone(), broadcastName: broadcast_name.clone(), trackName: track_name.clone(), }, } } pub fn eth_stream_control_announcement( value: &StreamControlAnnouncement, ) -> EthStreamControlAnnouncement { EthStreamControlAnnouncement { stream: eth_stream_descriptor(&value.stream), transports: value .transports .iter() .map(eth_stream_transport_descriptor) .collect(), updatedUnixMs: value.updated_unix_ms, ttlMs: value.ttl_ms, } } pub fn eth_chunk_id(value: &ChunkId) -> Result { Ok(EthChunkId { streamId: value.stream_id.0.clone(), epochId: value.epoch_id.clone(), chunkIndex: value.chunk_index, chunkHash: parse_b256(&value.chunk_hash)?, }) } pub fn eth_manifest_variant( value: &ManifestVariant, ) -> Result { Ok(EthManifestVariant { variantId: value.variant_id.clone(), streamId: value.stream_id.0.clone(), chunkStartIndex: value.chunk_start_index, totalChunks: value.total_chunks, merkleRoot: parse_b256(&value.merkle_root)?, chunkHashes: value .chunk_hashes .iter() .map(|hash| parse_b256(hash)) .collect::, _>>()?, metadata: eth_metadata(&value.metadata), }) } pub fn eth_manifest_body(value: &ManifestBody) -> Result { let variants = value .variants .as_ref() .map(|variants| { variants .iter() .map(eth_manifest_variant) .collect::, _>>() }) .transpose()? .unwrap_or_default(); Ok(EthManifestBody { streamId: value.stream_id.0.clone(), epochId: value.epoch_id.clone(), chunkDurationMs: value.chunk_duration_ms, totalChunks: value.total_chunks, chunkStartIndex: value.chunk_start_index, encoderProfileId: value.encoder_profile_id.clone(), merkleRoot: parse_b256(&value.merkle_root)?, createdUnixMs: value.created_unix_ms, metadata: eth_metadata(&value.metadata), chunkHashes: value .chunk_hashes .iter() .map(|hash| parse_b256(hash)) .collect::, _>>()?, variants, }) } pub fn manifest_body_eip712_signing_hash(value: &ManifestBody) -> Result { Ok(eth_manifest_body(value)?.eip712_signing_hash(&manifest_eip712_domain())) } pub fn eth_manifest_signature(value: &ManifestSignature) -> EthManifestSignature { EthManifestSignature { signerId: value.signer_id.clone(), alg: value.alg.clone(), signature: parse_bytes(&value.signature).into(), } } pub fn eth_manifest(value: &Manifest) -> Result { Ok(EthManifest { body: eth_manifest_body(&value.body)?, manifestId: parse_b256(&value.manifest_id)?, signatures: value .signatures .iter() .map(eth_manifest_signature) .collect(), }) } fn keccak_merkle_root(leaves: &[B256]) -> Result { if leaves.is_empty() { return Err(EthCommitmentError::Empty); } let mut nodes = leaves.to_vec(); while nodes.len() > 1 { if nodes.len() % 2 == 1 { if let Some(last) = nodes.last().copied() { nodes.push(last); } } let mut parents = Vec::with_capacity(nodes.len() / 2); for pair in nodes.chunks(2) { let mut merged = [0u8; 64]; merged[..32].copy_from_slice(pair[0].as_slice()); merged[32..].copy_from_slice(pair[1].as_slice()); parents.push(keccak256(merged)); } nodes = parents; } Ok(nodes[0]) } pub fn ethereum_merkle_root_from_hashes(hashes: &[String]) -> Result { let leaves = hashes .iter() .map(|hash| parse_b256(hash)) .collect::, _>>()?; Ok(format!("0x{}", hex::encode(keccak_merkle_root(&leaves)?))) } pub fn ethereum_merkle_proof_for_index( hashes: &[String], index: usize, ) -> Result, EthCommitmentError> { if hashes.is_empty() { return Err(EthCommitmentError::Empty); } if index >= hashes.len() { return Err(EthCommitmentError::InvalidHex(format!( "index {index} out of bounds" ))); } let mut nodes = hashes .iter() .map(|hash| parse_b256(hash)) .collect::, _>>()?; let mut proof = Vec::new(); let mut pos = index; while nodes.len() > 1 { if nodes.len() % 2 == 1 { if let Some(last) = nodes.last().copied() { nodes.push(last); } } let sibling_index = if pos % 2 == 0 { pos + 1 } else { pos - 1 }; let sibling = nodes[sibling_index]; proof.push(format!("0x{}", hex::encode(sibling))); let mut parents = Vec::with_capacity(nodes.len() / 2); for pair in nodes.chunks(2) { let mut merged = [0u8; 64]; merged[..32].copy_from_slice(pair[0].as_slice()); merged[32..].copy_from_slice(pair[1].as_slice()); parents.push(keccak256(merged)); } nodes = parents; pos /= 2; } Ok(proof) } pub fn verify_ethereum_merkle_proof( leaf_hash: &str, mut index: usize, branch: &[String], expected_root: &str, ) -> bool { let Ok(mut acc) = parse_b256(leaf_hash) else { return false; }; for sibling_hex in branch { let Ok(sibling) = parse_b256(sibling_hex) else { return false; }; let (left, right) = if index % 2 == 0 { (acc, sibling) } else { (sibling, acc) }; let mut merged = [0u8; 64]; merged[..32].copy_from_slice(left.as_slice()); merged[32..].copy_from_slice(right.as_slice()); acc = keccak256(merged); index /= 2; } match parse_b256(expected_root) { Ok(root) => acc == root, Err(_) => false, } } pub fn stream_id_commitment(stream_id: &str) -> ChainCommitment { commitment(SCHEME_STREAM_ID_KECCAK, keccak256(stream_id.as_bytes())) } pub fn broadcast_id_commitment(value: &BroadcastId) -> ChainCommitment { abi_commitment("broadcast-id-abi-keccak256-v1", ð_broadcast_id(value)) } pub fn stream_key_commitment(value: &StreamKey) -> ChainCommitment { abi_commitment("stream-key-abi-keccak256-v1", ð_stream_key(value)) } pub fn stream_descriptor_commitments(value: &StreamDescriptor) -> Vec { vec![ stream_id_commitment(&value.id.0), abi_commitment(SCHEME_STREAM_DESCRIPTOR_ABI, ð_stream_descriptor(value)), ] } pub fn control_announcement_commitments(value: &StreamControlAnnouncement) -> Vec { vec![abi_commitment( SCHEME_CONTROL_ANNOUNCEMENT_ABI, ð_stream_control_announcement(value), )] } fn manifest_data_root_commitment( body: &ManifestBody, ) -> Result { let root = if let Some(variants) = body .variants .as_ref() .filter(|variants| !variants.is_empty()) { let roots = variants .iter() .map(|variant| variant.merkle_root.clone()) .collect::>(); ethereum_merkle_root_from_hashes(&roots)? } else { ethereum_merkle_root_from_hashes(&body.chunk_hashes)? }; Ok(ChainCommitment { chain: ETHEREUM_CHAIN.to_string(), scheme: SCHEME_MANIFEST_DATA_ROOT.to_string(), digest: root, }) } pub fn manifest_commitments(value: &Manifest) -> Result, EthCommitmentError> { let eth_body = eth_manifest_body(&value.body)?; let eth_manifest = eth_manifest(value)?; Ok(vec![ manifest_data_root_commitment(&value.body)?, abi_commitment(SCHEME_MANIFEST_BODY_ABI, ð_body), abi_commitment(SCHEME_MANIFEST_ENVELOPE_ABI, ð_manifest), ]) } pub fn manifest_commitment_digest( value: &Manifest, scheme: &str, ) -> Result, EthCommitmentError> { Ok(manifest_commitments(value)? .into_iter() .find(|commitment| commitment.scheme == scheme) .map(|commitment| commitment.digest)) } pub fn manifest_observation_header( value: &Manifest, parent_observation_hash: Option<&str>, locator_hash: &str, sequence: u64, ) -> Result { let data_root = manifest_commitment_digest(value, SCHEME_MANIFEST_DATA_ROOT)? .ok_or(EthCommitmentError::Empty)?; Ok(EthObservationHeader { streamHash: keccak256(value.body.stream_id.0.as_bytes()), epochHash: keccak256(value.body.epoch_id.as_bytes()), parentObservationHash: parse_b256(parent_observation_hash.unwrap_or(ZERO_B256_HEX))?, dataRoot: parse_b256(&data_root)?, locatorHash: parse_b256(locator_hash)?, observedUnixMs: value.body.created_unix_ms, sequence, }) } pub fn observation_header_hash(value: &EthObservationHeader) -> String { b256_hex(keccak256(value.abi_encode())) } pub fn observation_slot_hash( stream_hash: &str, epoch_hash: &str, ) -> Result { let stream_hash = parse_b256(stream_hash)?; let epoch_hash = parse_b256(epoch_hash)?; Ok(b256_hex(keccak256((stream_hash, epoch_hash).abi_encode()))) } pub fn manifest_commitments_match(value: &Manifest) -> Result { let present = value .commitments .iter() .filter(|item| item.chain == ETHEREUM_CHAIN) .collect::>(); if present.is_empty() { return Ok(true); } let expected = manifest_commitments(value)?; Ok(present.into_iter().all(|actual| expected.contains(actual))) } #[cfg(test)] mod tests { use super::*; use ec_core::{ManifestBody, StreamId}; fn sample_body() -> ManifestBody { let chunk_hashes = vec![ blake3::hash(b"chunk0").to_hex().to_string(), blake3::hash(b"chunk1").to_hex().to_string(), ]; let merkle_root = ec_core::merkle_root_from_hashes(&chunk_hashes).unwrap(); ManifestBody { stream_id: StreamId("ec/stream/v1/broadcast/atsc/tsid-42/program-3".to_string()), epoch_id: "epoch-1".to_string(), chunk_duration_ms: 2000, total_chunks: 2, chunk_start_index: 10, encoder_profile_id: "deterministic-h264-aac".to_string(), merkle_root, created_unix_ms: 1234, metadata: vec![StreamMetadata { key: "callsign".to_string(), value: "KCBS".to_string(), }], chunk_hashes, variants: None, } } #[test] fn keccak_merkle_root_and_proof_roundtrip() { let body = sample_body(); let root = ethereum_merkle_root_from_hashes(&body.chunk_hashes).unwrap(); let proof = ethereum_merkle_proof_for_index(&body.chunk_hashes, 1).unwrap(); assert!(verify_ethereum_merkle_proof( &body.chunk_hashes[1], 1, &proof, &root )); } #[test] fn manifest_commitments_are_stable_and_match_present_values() { let body = sample_body(); let manifest_id = body.manifest_id().unwrap(); let mut manifest = Manifest { body, manifest_id, signatures: Vec::new(), commitments: Vec::new(), }; let commitments = manifest_commitments(&manifest).unwrap(); assert_eq!(commitments.len(), 3); manifest.commitments = commitments.clone(); assert!(manifest_commitments_match(&manifest).unwrap()); manifest.commitments[0].digest = "0xdeadbeef".to_string(); assert!(!manifest_commitments_match(&manifest).unwrap()); } #[test] fn manifest_body_eip712_hash_is_stable() { let body = sample_body(); let h1 = manifest_body_eip712_signing_hash(&body).unwrap(); let h2 = manifest_body_eip712_signing_hash(&body).unwrap(); assert_eq!(h1, h2); } #[test] fn manifest_observation_header_uses_manifest_data_root() { let body = sample_body(); let manifest_id = body.manifest_id().unwrap(); let mut manifest = Manifest { body, manifest_id, signatures: Vec::new(), commitments: Vec::new(), }; manifest.commitments = manifest_commitments(&manifest).unwrap(); let locator_hash = keccak256_bytes_hex(b"locator"); let header = manifest_observation_header(&manifest, None, &locator_hash, 7).unwrap(); assert_eq!(header.sequence, 7); assert_eq!(header.observedUnixMs, manifest.body.created_unix_ms); assert_eq!( b256_hex(header.dataRoot), manifest_commitment_digest(&manifest, SCHEME_MANIFEST_DATA_ROOT) .unwrap() .unwrap() ); assert_eq!( observation_slot_hash(&b256_hex(header.streamHash), &b256_hex(header.epochHash)) .unwrap(), b256_hex(keccak256( (header.streamHash, header.epochHash).abi_encode() )) ); } #[test] fn stream_descriptor_commitments_include_stream_id_and_descriptor_hashes() { let descriptor = StreamDescriptor { id: StreamId("ec/stream/v1/source/test/device-a/channel-b".to_string()), title: "Test".to_string(), number: Some("2.1".to_string()), source: "control".to_string(), metadata: vec![StreamMetadata { key: "broadcast".to_string(), value: "la-nbc".to_string(), }], commitments: Vec::new(), }; let commitments = stream_descriptor_commitments(&descriptor); assert_eq!(commitments.len(), 2); assert_eq!(commitments[0].scheme, SCHEME_STREAM_ID_KECCAK); assert_eq!(commitments[1].scheme, SCHEME_STREAM_DESCRIPTOR_ABI); } }