Advance forge rollout, Ethereum rails, and NBC sources

This commit is contained in:
every.channel 2026-04-01 15:58:49 -07:00
parent be26313225
commit 7d84510eac
No known key found for this signature in database
88 changed files with 11230 additions and 302 deletions

12
crates/ec-eth/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "ec-eth"
version = "0.0.0"
edition.workspace = true
license.workspace = true
[dependencies]
alloy-primitives = "1.5.7"
alloy-sol-types = "1.5.7"
blake3 = "1"
ec-core = { path = "../ec-core" }
hex = "0.4"

642
crates/ec-eth/src/lib.rs Normal file
View file

@ -0,0 +1,642 @@
//! 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";
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;
}
}
#[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: format!("0x{}", hex::encode(digest)),
}
}
fn abi_commitment<T: SolValue>(scheme: &str, value: &T) -> ChainCommitment {
commitment(scheme, keccak256(value.abi_encode()))
}
fn parse_b256(value: &str) -> Result<B256, EthCommitmentError> {
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<u8> {
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<EthStreamMetadata> {
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<EthChunkId, EthCommitmentError> {
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<EthManifestVariant, EthCommitmentError> {
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::<Result<Vec<_>, _>>()?,
metadata: eth_metadata(&value.metadata),
})
}
pub fn eth_manifest_body(value: &ManifestBody) -> Result<EthManifestBody, EthCommitmentError> {
let variants = value
.variants
.as_ref()
.map(|variants| {
variants
.iter()
.map(eth_manifest_variant)
.collect::<Result<Vec<_>, _>>()
})
.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::<Result<Vec<_>, _>>()?,
variants,
})
}
pub fn manifest_body_eip712_signing_hash(value: &ManifestBody) -> Result<B256, EthCommitmentError> {
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<EthManifest, EthCommitmentError> {
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<B256, EthCommitmentError> {
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<String, EthCommitmentError> {
let leaves = hashes
.iter()
.map(|hash| parse_b256(hash))
.collect::<Result<Vec<_>, _>>()?;
Ok(format!("0x{}", hex::encode(keccak_merkle_root(&leaves)?)))
}
pub fn ethereum_merkle_proof_for_index(
hashes: &[String],
index: usize,
) -> Result<Vec<String>, 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::<Result<Vec<_>, _>>()?;
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", &eth_broadcast_id(value))
}
pub fn stream_key_commitment(value: &StreamKey) -> ChainCommitment {
abi_commitment("stream-key-abi-keccak256-v1", &eth_stream_key(value))
}
pub fn stream_descriptor_commitments(value: &StreamDescriptor) -> Vec<ChainCommitment> {
vec![
stream_id_commitment(&value.id.0),
abi_commitment(SCHEME_STREAM_DESCRIPTOR_ABI, &eth_stream_descriptor(value)),
]
}
pub fn control_announcement_commitments(value: &StreamControlAnnouncement) -> Vec<ChainCommitment> {
vec![abi_commitment(
SCHEME_CONTROL_ANNOUNCEMENT_ABI,
&eth_stream_control_announcement(value),
)]
}
fn manifest_data_root_commitment(
body: &ManifestBody,
) -> Result<ChainCommitment, EthCommitmentError> {
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::<Vec<_>>();
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<Vec<ChainCommitment>, 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, &eth_body),
abi_commitment(SCHEME_MANIFEST_ENVELOPE_ABI, &eth_manifest),
])
}
pub fn manifest_commitments_match(value: &Manifest) -> Result<bool, EthCommitmentError> {
let present = value
.commitments
.iter()
.filter(|item| item.chain == ETHEREUM_CHAIN)
.collect::<Vec<_>>();
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 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);
}
}