diff --git a/crates/wzp-android/src/engine.rs b/crates/wzp-android/src/engine.rs index 476ed82..751d976 100644 --- a/crates/wzp-android/src/engine.rs +++ b/crates/wzp-android/src/engine.rs @@ -23,7 +23,7 @@ use wzp_fec::{RaptorQFecDecoder, RaptorQFecEncoder}; use wzp_proto::{ AdaptiveQualityController, AudioDecoder, AudioEncoder, CodecId, FecDecoder, FecEncoder, MediaHeader, MediaPacket, MediaTransport, MediaType, QualityController, QualityProfile, - SignalMessage, + SignalMessage, default_signal_version, }; use crate::audio_ring::AudioRing; @@ -321,11 +321,12 @@ impl WzpEngine { // Auth if token provided if let Some(ref tok) = token { - let _ = transport.send_signal(&SignalMessage::AuthToken { token: tok.clone() }).await; + let _ = transport.send_signal(&SignalMessage::AuthToken { version: default_signal_version(), token: tok.clone() }).await; } // Register presence let _ = transport.send_signal(&SignalMessage::RegisterPresence { + version: default_signal_version(), identity_pub, signature: vec![], alias: alias.clone(), @@ -350,7 +351,7 @@ impl WzpEngine { break; } match transport.recv_signal().await { - Ok(Some(SignalMessage::CallRinging { call_id })) => { + Ok(Some(SignalMessage::CallRinging { call_id, ..})) => { info!(call_id = %call_id, "signal: ringing"); let mut stats = signal_state.stats.lock().unwrap(); stats.state = crate::stats::CallState::Ringing; @@ -522,6 +523,7 @@ async fn run_call( let signature = kx.sign(&sign_data); let offer = SignalMessage::CallOffer { + version: default_signal_version(), identity_pub, ephemeral_pub, signature, @@ -1223,6 +1225,7 @@ async fn run_call( Ok(Some(SignalMessage::RoomUpdate { count, participants, + .. })) => { info!(count, "RoomUpdate received"); let members: Vec = participants @@ -1240,6 +1243,7 @@ async fn run_call( Ok(Some(SignalMessage::QualityDirective { recommended_profile, reason, + .. })) => { let idx = profile_to_index(&recommended_profile); info!( diff --git a/crates/wzp-client/src/analyzer.rs b/crates/wzp-client/src/analyzer.rs index 54553ac..3f06319 100644 --- a/crates/wzp-client/src/analyzer.rs +++ b/crates/wzp-client/src/analyzer.rs @@ -15,7 +15,7 @@ use std::time::{Duration, Instant}; use clap::Parser; use tracing::info; -use wzp_proto::{CodecId, MediaPacket, MediaTransport}; +use wzp_proto::{CodecId, MediaPacket, MediaTransport, default_signal_version}; // --------------------------------------------------------------------------- // CLI @@ -919,6 +919,7 @@ async fn main() -> anyhow::Result<()> { // Auth if token provided if let Some(ref token) = args.token { let auth = wzp_proto::SignalMessage::AuthToken { + version: default_signal_version(), token: token.clone(), }; transport.send_signal(&auth).await?; diff --git a/crates/wzp-client/src/cli.rs b/crates/wzp-client/src/cli.rs index 43a8d7a..573605b 100644 --- a/crates/wzp-client/src/cli.rs +++ b/crates/wzp-client/src/cli.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use tracing::{error, info}; use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; -use wzp_proto::MediaTransport; +use wzp_proto::{MediaTransport, default_signal_version}; const FRAME_SAMPLES: usize = 960; // 20ms @ 48kHz @@ -380,6 +380,7 @@ async fn main() -> anyhow::Result<()> { // Send auth token if provided (relay with --auth-url expects this first) if let Some(ref token) = cli.token { let auth = wzp_proto::SignalMessage::AuthToken { + version: default_signal_version(), token: token.clone(), }; transport.send_signal(&auth).await?; @@ -473,6 +474,7 @@ async fn run_silence(transport: Arc) -> anyhow::R info!(total_source, total_repair, total_bytes, "done — closing"); let hangup = wzp_proto::SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }; @@ -632,6 +634,7 @@ async fn run_file_mode( // Send Hangup signal so the relay knows we're done let hangup = wzp_proto::SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }; @@ -769,7 +772,7 @@ async fn run_signal_mode( token: Option, call_target: Option, ) -> anyhow::Result<()> { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; let identity = seed.derive_identity(); let pub_id = identity.public_identity(); @@ -792,13 +795,17 @@ async fn run_signal_mode( // Auth if token provided if let Some(ref tok) = token { transport - .send_signal(&SignalMessage::AuthToken { token: tok.clone() }) + .send_signal(&SignalMessage::AuthToken { + version: default_signal_version(), + token: tok.clone(), + }) .await?; } // Register presence (signature not verified in Phase 1) transport .send_signal(&SignalMessage::RegisterPresence { + version: default_signal_version(), identity_pub, signature: vec![], // Phase 1: not verified alias: None, @@ -835,6 +842,7 @@ async fn run_signal_mode( transport .send_signal(&SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: fp.clone(), caller_alias: None, target_fingerprint: target.clone(), @@ -861,7 +869,7 @@ async fn run_signal_mode( loop { match signal_transport.recv_signal().await { Ok(Some(msg)) => match msg { - SignalMessage::CallRinging { call_id } => { + SignalMessage::CallRinging { call_id, .. } => { info!(call_id = %call_id, "ringing..."); } SignalMessage::DirectCallOffer { @@ -879,6 +887,7 @@ async fn run_signal_mode( // Auto-accept for CLI testing let _ = signal_transport .send_signal(&SignalMessage::DirectCallAnswer { + version: default_signal_version(), call_id, accept_mode: wzp_proto::CallAcceptMode::AcceptGeneric, identity_pub: Some(identity_pub), @@ -908,6 +917,7 @@ async fn run_signal_mode( peer_direct_addr: _, peer_local_addrs: _, peer_mapped_addr: _, + .. } => { info!(call_id = %call_id, room = %room, relay = %setup_relay, "call setup — connecting to media room"); @@ -970,6 +980,7 @@ async fn run_signal_mode( _ = tokio::signal::ctrl_c() => { info!("hanging up..."); let _ = signal_transport.send_signal(&SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }).await; diff --git a/crates/wzp-client/src/featherchat.rs b/crates/wzp-client/src/featherchat.rs index 3be0b55..f6bb4c7 100644 --- a/crates/wzp-client/src/featherchat.rs +++ b/crates/wzp-client/src/featherchat.rs @@ -11,7 +11,7 @@ //! 5. Connects QUIC to relay for media use serde::{Deserialize, Serialize}; -use wzp_proto::packet::SignalMessage; +use wzp_proto::packet::{SignalMessage, default_signal_version}; /// featherChat CallSignal types (mirrors warzone-protocol::message::CallSignalType). #[derive(Clone, Debug, Serialize, Deserialize)] @@ -152,6 +152,7 @@ mod tests { #[test] fn payload_roundtrip() { let signal = SignalMessage::CallOffer { + version: default_signal_version(), identity_pub: [1u8; 32], ephemeral_pub: [2u8; 32], signature: vec![3u8; 64], @@ -172,6 +173,7 @@ mod tests { #[test] fn signal_type_mapping() { let offer = SignalMessage::CallOffer { + version: default_signal_version(), identity_pub: [0; 32], ephemeral_pub: [0; 32], signature: vec![], @@ -183,6 +185,7 @@ mod tests { assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer)); let hangup = SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }; @@ -209,6 +212,7 @@ mod tests { )); let transfer = SignalMessage::Transfer { + version: default_signal_version(), target_fingerprint: "abc".to_string(), relay_addr: None, }; diff --git a/crates/wzp-client/src/handshake.rs b/crates/wzp-client/src/handshake.rs index 8e5ec63..dd14419 100644 --- a/crates/wzp-client/src/handshake.rs +++ b/crates/wzp-client/src/handshake.rs @@ -4,7 +4,9 @@ //! send `CallOffer` → recv `CallAnswer` → derive shared `CryptoSession`. use wzp_crypto::{CryptoSession, KeyExchange, WarzoneKeyExchange}; -use wzp_proto::{HangupReason, MediaTransport, QualityProfile, SignalMessage}; +use wzp_proto::{ + HangupReason, MediaTransport, QualityProfile, SignalMessage, default_signal_version, +}; /// Errors that can occur during the client-side cryptographic handshake. #[derive(Debug)] @@ -78,6 +80,7 @@ pub async fn perform_handshake( // 4. Send CallOffer let offer = SignalMessage::CallOffer { + version: default_signal_version(), identity_pub, ephemeral_pub, signature, @@ -112,6 +115,7 @@ pub async fn perform_handshake( ephemeral_pub, signature, chosen_profile, + .. } => (identity_pub, ephemeral_pub, signature, chosen_profile), SignalMessage::Hangup { reason: HangupReason::ProtocolVersionMismatch { server_supported }, diff --git a/crates/wzp-client/src/ice_agent.rs b/crates/wzp-client/src/ice_agent.rs index 10ca001..9b1e6ef 100644 --- a/crates/wzp-client/src/ice_agent.rs +++ b/crates/wzp-client/src/ice_agent.rs @@ -17,7 +17,7 @@ use std::net::SocketAddr; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; -use wzp_proto::SignalMessage; +use wzp_proto::{SignalMessage, default_signal_version}; use crate::dual_path::PeerCandidates; use crate::portmap; @@ -133,6 +133,7 @@ impl IceAgent { let candidates = self.gather().await; let update = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: self.call_id.clone(), reflexive_addr: candidates.reflexive.map(|a| a.to_string()), local_addrs: candidates.local.iter().map(|a| a.to_string()).collect(), @@ -206,6 +207,7 @@ mod tests { // First update (gen=1) should succeed. let update1 = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test-call".into(), reflexive_addr: Some("203.0.113.5:4433".into()), local_addrs: vec!["192.168.1.10:4433".into()], @@ -223,6 +225,7 @@ mod tests { // Same generation (gen=1) should be rejected. let update1b = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test-call".into(), reflexive_addr: Some("198.51.100.9:4433".into()), local_addrs: vec![], @@ -233,6 +236,7 @@ mod tests { // Older generation (gen=0) should be rejected. let update0 = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test-call".into(), reflexive_addr: Some("10.0.0.1:4433".into()), local_addrs: vec![], @@ -243,6 +247,7 @@ mod tests { // Newer generation (gen=2) should succeed. let update2 = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test-call".into(), reflexive_addr: Some("198.51.100.9:5555".into()), local_addrs: vec![], @@ -287,6 +292,7 @@ mod tests { let agent = IceAgent::new("test-call".into(), IceAgentConfig::default()); let update = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test-call".into(), reflexive_addr: Some("203.0.113.5:4433".into()), local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()], @@ -315,6 +321,7 @@ mod tests { let agent = IceAgent::new("test".into(), IceAgentConfig::default()); let update = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test".into(), reflexive_addr: None, local_addrs: vec![], @@ -333,6 +340,7 @@ mod tests { let agent = IceAgent::new("test".into(), IceAgentConfig::default()); let update = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test".into(), reflexive_addr: Some("not-an-addr".into()), local_addrs: vec![ diff --git a/crates/wzp-client/src/reflect.rs b/crates/wzp-client/src/reflect.rs index 9a28f2b..cf2f743 100644 --- a/crates/wzp-client/src/reflect.rs +++ b/crates/wzp-client/src/reflect.rs @@ -30,7 +30,7 @@ use std::net::SocketAddr; use std::time::{Duration, Instant}; use serde::Serialize; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; use wzp_transport::{QuinnTransport, client_config, create_endpoint}; /// Result of one probe against one relay. Always returned so the @@ -123,6 +123,7 @@ pub async fn probe_reflect_addr( // path does in desktop/src-tauri/src/lib.rs register_signal. transport .send_signal(&SignalMessage::RegisterPresence { + version: default_signal_version(), identity_pub: [0u8; 32], signature: vec![], alias: None, @@ -150,7 +151,7 @@ pub async fn probe_reflect_addr( .map_err(|e| format!("send Reflect: {e}"))?; match transport.recv_signal().await { - Ok(Some(SignalMessage::ReflectResponse { observed_addr })) => { + Ok(Some(SignalMessage::ReflectResponse { observed_addr, .. })) => { let parsed: SocketAddr = observed_addr .parse() .map_err(|e| format!("parse observed_addr {observed_addr:?}: {e}"))?; diff --git a/crates/wzp-client/tests/handshake_integration.rs b/crates/wzp-client/tests/handshake_integration.rs index 55dde70..8e1027a 100644 --- a/crates/wzp-client/tests/handshake_integration.rs +++ b/crates/wzp-client/tests/handshake_integration.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; use wzp_proto::packet::MediaPacket; use wzp_proto::traits::{MediaTransport, PathQuality}; -use wzp_proto::{SignalMessage, TransportError}; +use wzp_proto::{SignalMessage, TransportError, default_signal_version}; /// A mock transport backed by two mpsc channels (one per direction). /// @@ -151,6 +151,7 @@ async fn handshake_rejects_tampered_signature() { let bad_signature = kx.sign(b"wrong-data-intentionally"); let offer = SignalMessage::CallOffer { + version: default_signal_version(), identity_pub, ephemeral_pub, signature: bad_signature, @@ -197,6 +198,7 @@ async fn client_receives_protocol_version_mismatch() { // Respond with ProtocolVersionMismatch. let mismatch = SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::ProtocolVersionMismatch { server_supported: vec![3], }, diff --git a/crates/wzp-crypto/tests/featherchat_compat.rs b/crates/wzp-crypto/tests/featherchat_compat.rs index e236d80..cb8cdcd 100644 --- a/crates/wzp-crypto/tests/featherchat_compat.rs +++ b/crates/wzp-crypto/tests/featherchat_compat.rs @@ -6,7 +6,7 @@ //! 3. Auth: WZP auth module request/response matches FC's /v1/auth/validate contract //! 4. Mnemonic: BIP39 interop between both implementations -use wzp_proto::KeyExchange; +use wzp_proto::{KeyExchange, default_signal_version}; // ─── Identity Compatibility (WZP-FC-8) ────────────────────────────────────── @@ -114,6 +114,7 @@ fn mnemonic_strings_identical() { fn wzp_signal_serializes_into_fc_callsignal_payload() { // WZP creates a CallOffer SignalMessage let offer = wzp_proto::SignalMessage::CallOffer { + version: default_signal_version(), identity_pub: [1u8; 32], ephemeral_pub: [2u8; 32], signature: vec![3u8; 64], @@ -180,6 +181,7 @@ fn wzp_signal_serializes_into_fc_callsignal_payload() { #[test] fn wzp_answer_round_trips_through_fc_callsignal() { let answer = wzp_proto::SignalMessage::CallAnswer { + version: default_signal_version(), identity_pub: [10u8; 32], ephemeral_pub: [20u8; 32], signature: vec![30u8; 64], @@ -212,6 +214,7 @@ fn wzp_answer_round_trips_through_fc_callsignal() { #[test] fn wzp_hangup_round_trips_through_fc_callsignal() { let hangup = wzp_proto::SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }; @@ -298,6 +301,7 @@ fn all_signal_types_map_correctly() { let cases: Vec<(wzp_proto::SignalMessage, &str)> = vec![ ( wzp_proto::SignalMessage::CallOffer { + version: default_signal_version(), identity_pub: [0; 32], ephemeral_pub: [0; 32], signature: vec![], @@ -310,6 +314,7 @@ fn all_signal_types_map_correctly() { ), ( wzp_proto::SignalMessage::CallAnswer { + version: default_signal_version(), identity_pub: [0; 32], ephemeral_pub: [0; 32], signature: vec![], @@ -319,12 +324,14 @@ fn all_signal_types_map_correctly() { ), ( wzp_proto::SignalMessage::IceCandidate { + version: default_signal_version(), candidate: "candidate:1".to_string(), }, "IceCandidate", ), ( wzp_proto::SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }, @@ -482,10 +489,11 @@ fn auth_response_with_eth_address() { assert!(!resp3.valid); } -/// WZP-S-7: SignalMessage::AuthToken { token } exists and round-trips via serde. +/// WZP-S-7: SignalMessage::AuthToken { version: default_signal_version(), token } exists and round-trips via serde. #[test] fn wzp_proto_has_auth_token_variant() { let msg = wzp_proto::SignalMessage::AuthToken { + version: default_signal_version(), token: "fc-bearer-token-xyz".to_string(), }; @@ -496,7 +504,7 @@ fn wzp_proto_has_auth_token_variant() { // Deserialize back let decoded: wzp_proto::SignalMessage = serde_json::from_str(&json).unwrap(); - if let wzp_proto::SignalMessage::AuthToken { token } = decoded { + if let wzp_proto::SignalMessage::AuthToken { token, .. } = decoded { assert_eq!(token, "fc-bearer-token-xyz"); } else { panic!("expected AuthToken variant, got: {decoded:?}"); diff --git a/crates/wzp-proto/src/lib.rs b/crates/wzp-proto/src/lib.rs index 346e81a..cdcf154 100644 --- a/crates/wzp-proto/src/lib.rs +++ b/crates/wzp-proto/src/lib.rs @@ -32,7 +32,7 @@ pub use media_type::MediaType; pub use packet::{ CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV2, MediaPacket, MiniFrameContext, MiniFrameContextV2, MiniHeader, MiniHeaderV2, PresenceUser, - QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, + QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, default_signal_version, }; pub use quality::{AdaptiveQualityController, NetworkContext, Tier}; pub use session::{Session, SessionEvent, SessionState}; diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index 26b4cf0..a7b9f4c 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -540,10 +540,23 @@ impl MiniFrameContextV2 { /// Compatible with Warzone messenger's identity model: /// - Identity keys are Ed25519 (signing) + X25519 (encryption) derived from a 32-byte seed via HKDF /// - Fingerprint = SHA-256(Ed25519 public key)[:16] +/// +/// **Version field:** every struct variant carries `version: u8` (default 1). +/// Old payloads that omit `version` deserialize cleanly thanks to `#[serde(default)]`. +/// +/// **Unknown variant handling:** `#[serde(other)]` is designed for +/// string/integer enums with adjacent tagging, not for externally tagged enum +/// variants. With externally tagged representation (the default for Rust enums), +/// the variant name IS the tag, so there is no other value to catch. `bincode` +/// in particular does not support `#[serde(other)]`. Unknown variants will +/// naturally cause a deserialization error, which is the correct behavior for +/// the signal protocol. #[derive(Clone, Debug, Serialize, Deserialize)] pub enum SignalMessage { /// Call initiation (analogous to Warzone's WireMessage::CallOffer). CallOffer { + #[serde(default = "default_signal_version")] + version: u8, /// Caller's Ed25519 identity public key (32 bytes). identity_pub: [u8; 32], /// Ephemeral X25519 public key for this call. @@ -565,6 +578,8 @@ pub enum SignalMessage { /// Call acceptance (analogous to Warzone's WireMessage::CallAnswer). CallAnswer { + #[serde(default = "default_signal_version")] + version: u8, /// Callee's Ed25519 identity public key (32 bytes). identity_pub: [u8; 32], /// Callee's ephemeral X25519 public key. @@ -577,11 +592,15 @@ pub enum SignalMessage { /// ICE candidate for NAT traversal. IceCandidate { + #[serde(default = "default_signal_version")] + version: u8, candidate: String, }, /// Periodic rekeying (forward secrecy). Rekey { + #[serde(default = "default_signal_version")] + version: u8, /// New ephemeral X25519 public key. new_ephemeral_pub: [u8; 32], /// Ed25519 signature over (new_ephemeral_pub || session_id). @@ -590,6 +609,8 @@ pub enum SignalMessage { /// Quality/profile change request. QualityUpdate { + #[serde(default = "default_signal_version")] + version: u8, report: QualityReport, recommended_profile: crate::QualityProfile, }, @@ -601,6 +622,8 @@ pub enum SignalMessage { /// introducing this variant is backward-compatible with pre-Phase-4 /// relays — they'll just log "unknown signal variant" on receipt. LossRecoveryUpdate { + #[serde(default = "default_signal_version")] + version: u8, /// Total frames reconstructed via DRED since call start (monotonic). #[serde(default)] dred_reconstructions: u64, @@ -616,9 +639,13 @@ pub enum SignalMessage { /// Connection keepalive / RTT measurement. Ping { + #[serde(default = "default_signal_version")] + version: u8, timestamp_ms: u64, }, Pong { + #[serde(default = "default_signal_version")] + version: u8, timestamp_ms: u64, }, @@ -626,6 +653,8 @@ pub enum SignalMessage { /// with older clients that send Hangup without it — the relay falls /// back to ending ALL active calls for the sender in that case. Hangup { + #[serde(default = "default_signal_version")] + version: u8, reason: HangupReason, #[serde(default, skip_serializing_if = "Option::is_none")] call_id: Option, @@ -634,6 +663,8 @@ pub enum SignalMessage { /// featherChat bearer token for relay authentication. /// Sent as the first signal message when --auth-url is configured. AuthToken { + #[serde(default = "default_signal_version")] + version: u8, token: String, }, @@ -647,6 +678,8 @@ pub enum SignalMessage { Unmute, /// Transfer the call to another peer. Transfer { + #[serde(default = "default_signal_version")] + version: u8, target_fingerprint: String, /// Optional relay address for the transfer target. relay_addr: Option, @@ -658,6 +691,8 @@ pub enum SignalMessage { /// Sent periodically over probe connections to share which fingerprints /// are connected to the sending relay. PresenceUpdate { + #[serde(default = "default_signal_version")] + version: u8, /// Fingerprints currently connected to the sending relay. fingerprints: Vec, /// Address of the sending relay (e.g., "192.168.1.10:4433"). @@ -666,11 +701,15 @@ pub enum SignalMessage { /// Ask a peer relay to look up a fingerprint in its registry. RouteQuery { + #[serde(default = "default_signal_version")] + version: u8, fingerprint: String, ttl: u8, }, /// Response to a route query. RouteResponse { + #[serde(default = "default_signal_version")] + version: u8, fingerprint: String, found: bool, relay_chain: Vec, @@ -680,6 +719,8 @@ pub enum SignalMessage { /// Sent over a relay link (`_relay` SNI) to ask the peer relay to /// create a room and forward media for the given session. SessionForward { + #[serde(default = "default_signal_version")] + version: u8, session_id: String, target_fingerprint: String, source_relay: String, @@ -687,12 +728,16 @@ pub enum SignalMessage { /// Confirm that the forwarding session has been set up on the peer relay. /// The `room_name` tells the source relay which room to address media to. SessionForwardAck { + #[serde(default = "default_signal_version")] + version: u8, session_id: String, room_name: String, }, /// Room membership update — sent by relay to all participants when someone joins or leaves. RoomUpdate { + #[serde(default = "default_signal_version")] + version: u8, /// Current participant count. count: u32, /// List of participants currently in the room. @@ -702,12 +747,16 @@ pub enum SignalMessage { // ── Federation signals (relay-to-relay) ── /// Federation: initial handshake — the connecting relay identifies itself. FederationHello { + #[serde(default = "default_signal_version")] + version: u8, /// TLS certificate fingerprint of the connecting relay. tls_fingerprint: String, }, /// Federation: this relay now has local participants in a global room. GlobalRoomActive { + #[serde(default = "default_signal_version")] + version: u8, room: String, /// Participants on the announcing relay (for federated presence). #[serde(default)] @@ -716,6 +765,8 @@ pub enum SignalMessage { /// Federation: this relay's last local participant left a global room. GlobalRoomInactive { + #[serde(default = "default_signal_version")] + version: u8, room: String, }, @@ -723,6 +774,8 @@ pub enum SignalMessage { /// Register on relay for direct calls. Sent on `_signal` connections /// after optional AuthToken. RegisterPresence { + #[serde(default = "default_signal_version")] + version: u8, /// Client's Ed25519 identity public key. identity_pub: [u8; 32], /// Signature over ("register-presence" || identity_pub). @@ -733,6 +786,8 @@ pub enum SignalMessage { /// Relay confirms presence registration. RegisterPresenceAck { + #[serde(default = "default_signal_version")] + version: u8, success: bool, #[serde(skip_serializing_if = "Option::is_none")] error: Option, @@ -750,6 +805,8 @@ pub enum SignalMessage { /// Direct call offer routed through the relay to a specific peer. DirectCallOffer { + #[serde(default = "default_signal_version")] + version: u8, /// Caller's fingerprint. caller_fingerprint: String, /// Caller's display name. @@ -798,6 +855,8 @@ pub enum SignalMessage { /// Callee's response to a direct call. DirectCallAnswer { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// How the callee accepts (or rejects). accept_mode: CallAcceptMode, @@ -838,6 +897,8 @@ pub enum SignalMessage { /// Relay tells both parties: media room is ready. CallSetup { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// Room name on the relay for the media session (e.g., "_call:a1b2c3d4"). room: String, @@ -871,6 +932,8 @@ pub enum SignalMessage { /// Ringing notification (relay → caller, callee received the offer). CallRinging { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, }, @@ -893,6 +956,8 @@ pub enum SignalMessage { /// for IPv4, "[::1]:p" for IPv6. Clients parse it with /// `SocketAddr::from_str`. ReflectResponse { + #[serde(default = "default_signal_version")] + version: u8, observed_addr: String, }, @@ -910,6 +975,8 @@ pub enum SignalMessage { /// and the other picks Relay — they now agree on the path /// before any media flows. MediaPathReport { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// Did the direct QUIC connection (P2P dial or accept) /// complete successfully on this side? @@ -931,6 +998,8 @@ pub enum SignalMessage { /// — peers ignore updates with a generation <= their last-seen /// generation to handle reordering. CandidateUpdate { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// New server-reflexive address (STUN-discovered or relay-reflected). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -951,6 +1020,8 @@ pub enum SignalMessage { /// and recent port sequence so the peer can predict which port /// to dial. HardNatProbe { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// Last observed external ports (most recent first). /// Typically 3-5 entries from sequential STUN probes. @@ -968,6 +1039,8 @@ pub enum SignalMessage { /// ports it has open. The Dialer then sprays QUIC connects to /// these ports (and optionally random ports) on the Acceptor's IP. HardNatBirthdayStart { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// Number of sockets the Acceptor opened. acceptor_port_count: u16, @@ -995,6 +1068,8 @@ pub enum SignalMessage { /// A→B→A echo loops; proper TTL + dedup will land when /// multi-hop federation is added (Phase 4.2). FederatedSignalForward { + #[serde(default = "default_signal_version")] + version: u8, /// The signal message being forwarded /// (`DirectCallOffer`, `DirectCallAnswer`, `CallRinging`, /// `Hangup`, ...). Boxed because `SignalMessage` is @@ -1011,6 +1086,8 @@ pub enum SignalMessage { /// Relay-initiated quality directive: all participants should switch /// to the recommended profile to match the weakest link. QualityDirective { + #[serde(default = "default_signal_version")] + version: u8, recommended_profile: crate::QualityProfile, #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, @@ -1021,6 +1098,8 @@ pub enum SignalMessage { /// users to all connected clients. Sent on every register/ /// deregister so clients can maintain a live lobby user list. PresenceList { + #[serde(default = "default_signal_version")] + version: u8, /// List of online users. Each entry is { fingerprint, alias }. users: Vec, }, @@ -1031,6 +1110,8 @@ pub enum SignalMessage { /// conditions. Used for consensual upgrades that require both /// sides to agree (e.g., switching from Opus24k to Studio48k). UpgradeProposal { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// Unique ID for this proposal (to match response). proposal_id: String, @@ -1045,6 +1126,8 @@ pub enum SignalMessage { /// Response to an UpgradeProposal. UpgradeResponse { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, proposal_id: String, /// true = accepted, both sides switch. false = rejected. @@ -1057,6 +1140,8 @@ pub enum SignalMessage { /// Confirmation that the upgrade is committed — both sides /// should switch encoder at the next frame boundary. UpgradeConfirm { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, proposal_id: String, confirmed_profile: crate::QualityProfile, @@ -1067,6 +1152,8 @@ pub enum SignalMessage { /// encoding where each side uses the best quality its connection /// supports, rather than forcing all to the weakest link. QualityCapability { + #[serde(default = "default_signal_version")] + version: u8, call_id: String, /// The best profile this peer can sustain based on its /// current network conditions. @@ -1083,7 +1170,7 @@ pub enum SignalMessage { /// carrying ACK/NACK vectors and a REMB-style bandwidth estimate. TransportFeedback { /// Feedback format version (default 1). - #[serde(default)] + #[serde(default = "default_signal_version")] version: u8, /// Which media stream this feedback applies to. stream_id: u8, @@ -1132,6 +1219,10 @@ pub fn default_proto_version() -> u8 { pub fn default_supported_versions() -> Vec { vec![2] } +/// Default signal message version (v1). +pub fn default_signal_version() -> u8 { + 1 +} /// Reasons for ending a call. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -1304,12 +1395,13 @@ mod tests { // for v6 and the client side has to parse that back. for addr in ["192.0.2.17:4433", "[2001:db8::1]:4433", "127.0.0.1:54321"] { let resp = SignalMessage::ReflectResponse { + version: default_signal_version(), observed_addr: addr.to_string(), }; let json = serde_json::to_string(&resp).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::ReflectResponse { observed_addr } => { + SignalMessage::ReflectResponse { observed_addr, .. } => { assert_eq!(observed_addr, addr); // Must parse back to a SocketAddr cleanly. let _parsed: std::net::SocketAddr = observed_addr @@ -1326,6 +1418,7 @@ mod tests { // Wrap a DirectCallOffer inside FederatedSignalForward and // prove both directions of serde preserve every field. let inner = SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: "alice".into(), caller_alias: Some("Alice".into()), target_fingerprint: "bob".into(), @@ -1340,6 +1433,7 @@ mod tests { caller_build_version: None, }; let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(inner), origin_relay_fp: "relay-a-tls-fp".into(), }; @@ -1349,6 +1443,7 @@ mod tests { SignalMessage::FederatedSignalForward { inner, origin_relay_fp, + .. } => { assert_eq!(origin_relay_fp, "relay-a-tls-fp"); match *inner { @@ -1375,6 +1470,7 @@ mod tests { // we intend to forward survives being boxed + re-serialized. let cases: Vec = vec![ SignalMessage::DirectCallAnswer { + version: default_signal_version(), call_id: "c1".into(), accept_mode: CallAcceptMode::AcceptTrusted, identity_pub: None, @@ -1387,9 +1483,11 @@ mod tests { callee_build_version: None, }, SignalMessage::CallRinging { + version: default_signal_version(), call_id: "c1".into(), }, SignalMessage::Hangup { + version: default_signal_version(), reason: HangupReason::Normal, call_id: None, }, @@ -1397,6 +1495,7 @@ mod tests { for inner in cases { let inner_disc = std::mem::discriminant(&inner); let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(inner), origin_relay_fp: "r".into(), }; @@ -1415,6 +1514,7 @@ mod tests { fn hole_punching_optional_fields_roundtrip() { // DirectCallOffer with Some(caller_reflexive_addr) let offer = SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: "alice".into(), caller_alias: None, target_fingerprint: "bob".into(), @@ -1448,6 +1548,7 @@ mod tests { // OMIT the field from the JSON so older relays that don't // know about caller_reflexive_addr don't see it. let offer_none = SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: "alice".into(), caller_alias: None, target_fingerprint: "bob".into(), @@ -1469,6 +1570,7 @@ mod tests { // DirectCallAnswer with callee_reflexive_addr. let answer = SignalMessage::DirectCallAnswer { + version: default_signal_version(), call_id: "c1".into(), accept_mode: CallAcceptMode::AcceptTrusted, identity_pub: None, @@ -1494,6 +1596,7 @@ mod tests { // CallSetup with peer_direct_addr. let setup = SignalMessage::CallSetup { + version: default_signal_version(), call_id: "c1".into(), room: "call-c1".into(), relay_addr: "203.0.113.5:4433".into(), @@ -1566,14 +1669,17 @@ mod tests { // test a sample of the pre-existing ones. let cases = vec![ SignalMessage::Ping { + version: default_signal_version(), timestamp_ms: 12345, }, SignalMessage::Hold, SignalMessage::Hangup { + version: default_signal_version(), reason: HangupReason::Normal, call_id: None, }, SignalMessage::CallRinging { + version: default_signal_version(), call_id: "abcd".into(), }, ]; @@ -1614,6 +1720,7 @@ mod tests { #[test] fn transfer_serialize() { let transfer = SignalMessage::Transfer { + version: default_signal_version(), target_fingerprint: "abc123".to_string(), relay_addr: Some("relay.example.com:4433".to_string()), }; @@ -1623,6 +1730,7 @@ mod tests { SignalMessage::Transfer { target_fingerprint, relay_addr, + .. } => { assert_eq!(target_fingerprint, "abc123"); assert_eq!(relay_addr.unwrap(), "relay.example.com:4433"); @@ -1632,6 +1740,7 @@ mod tests { // Also test with relay_addr = None let transfer_no_relay = SignalMessage::Transfer { + version: default_signal_version(), target_fingerprint: "def456".to_string(), relay_addr: None, }; @@ -1641,6 +1750,7 @@ mod tests { SignalMessage::Transfer { target_fingerprint, relay_addr, + .. } => { assert_eq!(target_fingerprint, "def456"); assert!(relay_addr.is_none()); @@ -1660,6 +1770,7 @@ mod tests { #[test] fn presence_update_signal_roundtrip() { let msg = SignalMessage::PresenceUpdate { + version: default_signal_version(), fingerprints: vec!["aabb".to_string(), "ccdd".to_string()], relay_addr: "10.0.0.1:4433".to_string(), }; @@ -1669,6 +1780,7 @@ mod tests { SignalMessage::PresenceUpdate { fingerprints, relay_addr, + .. } => { assert_eq!(fingerprints.len(), 2); assert!(fingerprints.contains(&"aabb".to_string())); @@ -1680,6 +1792,7 @@ mod tests { // Empty fingerprints list let msg_empty = SignalMessage::PresenceUpdate { + version: default_signal_version(), fingerprints: vec![], relay_addr: "10.0.0.2:4433".to_string(), }; @@ -1689,6 +1802,7 @@ mod tests { SignalMessage::PresenceUpdate { fingerprints, relay_addr, + .. } => { assert!(fingerprints.is_empty()); assert_eq!(relay_addr, "10.0.0.2:4433"); @@ -1999,6 +2113,7 @@ mod tests { #[test] fn quality_directive_roundtrip() { let msg = SignalMessage::QualityDirective { + version: default_signal_version(), recommended_profile: crate::QualityProfile::DEGRADED, reason: Some("weakest link degraded".into()), }; @@ -2008,6 +2123,7 @@ mod tests { SignalMessage::QualityDirective { recommended_profile, reason, + .. } => { assert_eq!(recommended_profile.codec, CodecId::Opus6k); assert_eq!(reason.as_deref(), Some("weakest link degraded")); @@ -2019,6 +2135,7 @@ mod tests { #[test] fn quality_directive_without_reason_roundtrip() { let msg = SignalMessage::QualityDirective { + version: default_signal_version(), recommended_profile: crate::QualityProfile::GOOD, reason: None, }; @@ -2078,6 +2195,7 @@ mod tests { #[test] fn upgrade_proposal_roundtrip() { let msg = SignalMessage::UpgradeProposal { + version: default_signal_version(), call_id: "c1".into(), proposal_id: "p1".into(), proposed_profile: crate::QualityProfile::STUDIO_48K, @@ -2102,6 +2220,7 @@ mod tests { #[test] fn upgrade_response_roundtrip() { let msg = SignalMessage::UpgradeResponse { + version: default_signal_version(), call_id: "c1".into(), proposal_id: "p1".into(), accepted: true, @@ -2118,6 +2237,7 @@ mod tests { #[test] fn upgrade_confirm_roundtrip() { let msg = SignalMessage::UpgradeConfirm { + version: default_signal_version(), call_id: "c1".into(), proposal_id: "p1".into(), confirmed_profile: crate::QualityProfile::STUDIO_64K, @@ -2137,6 +2257,7 @@ mod tests { #[test] fn quality_capability_roundtrip() { let msg = SignalMessage::QualityCapability { + version: default_signal_version(), call_id: "c1".into(), max_profile: crate::QualityProfile::GOOD, loss_pct: Some(2.5), @@ -2162,6 +2283,7 @@ mod tests { #[test] fn candidate_update_roundtrip() { let msg = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "test-123".into(), reflexive_addr: Some("203.0.113.5:4433".into()), local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()], @@ -2177,6 +2299,7 @@ mod tests { local_addrs, mapped_addr, generation, + .. } => { assert_eq!(call_id, "test-123"); assert_eq!(reflexive_addr.as_deref(), Some("203.0.113.5:4433")); @@ -2191,6 +2314,7 @@ mod tests { #[test] fn candidate_update_minimal_roundtrip() { let msg = SignalMessage::CandidateUpdate { + version: default_signal_version(), call_id: "c".into(), reflexive_addr: None, local_addrs: vec![], @@ -2215,6 +2339,7 @@ mod tests { #[test] fn offer_with_mapped_addr_roundtrip() { let msg = SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: "alice".into(), caller_alias: None, target_fingerprint: "bob".into(), @@ -2246,6 +2371,7 @@ mod tests { #[test] fn offer_without_mapped_addr_omits_field() { let msg = SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: "alice".into(), caller_alias: None, target_fingerprint: "bob".into(), @@ -2266,6 +2392,7 @@ mod tests { #[test] fn answer_with_mapped_addr_roundtrip() { let msg = SignalMessage::DirectCallAnswer { + version: default_signal_version(), call_id: "c1".into(), accept_mode: CallAcceptMode::AcceptTrusted, identity_pub: None, @@ -2292,6 +2419,7 @@ mod tests { #[test] fn setup_with_mapped_addr_roundtrip() { let msg = SignalMessage::CallSetup { + version: default_signal_version(), call_id: "c1".into(), room: "room".into(), relay_addr: "1.2.3.4:5".into(), @@ -2368,6 +2496,7 @@ mod tests { #[test] fn register_presence_ack_with_new_fields_roundtrip() { let msg = SignalMessage::RegisterPresenceAck { + version: default_signal_version(), success: true, error: None, relay_build: Some("abc123".into()), @@ -2443,6 +2572,7 @@ mod tests { nacked_seqs, remb_bps, recv_time_us, + .. } => { assert_eq!(version, 1); assert_eq!(stream_id, 0); @@ -2475,7 +2605,76 @@ mod tests { let decoded: SignalMessage = serde_json::from_str(json).unwrap(); match decoded { SignalMessage::TransportFeedback { version, .. } => { - assert_eq!(version, 0, "serde default makes omitted version 0"); + assert_eq!(version, 1, "serde default makes omitted version 1"); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn old_payload_without_version_deserializes() { + // CallOffer without version field — old client sending to new receiver. + let json = r#"{ + "CallOffer": { + "identity_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + "ephemeral_pub": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + "signature": [], + "supported_profiles": [], + "alias": null, + "protocol_version": 2, + "supported_versions": [2] + } + }"#; + let decoded: SignalMessage = serde_json::from_str(json).unwrap(); + match decoded { + SignalMessage::CallOffer { + version, + protocol_version, + .. + } => { + assert_eq!(version, 1, "missing version defaults to 1"); + assert_eq!(protocol_version, 2); + } + _ => panic!("wrong variant"), + } + + // Ping without version field. + let json = r#"{"Ping": {"timestamp_ms": 1234}}"#; + let decoded: SignalMessage = serde_json::from_str(json).unwrap(); + match decoded { + SignalMessage::Ping { + version, + timestamp_ms, + } => { + assert_eq!(version, 1, "missing version defaults to 1"); + assert_eq!(timestamp_ms, 1234); + } + _ => panic!("wrong variant"), + } + + // Hangup without version field. + let json = r#"{"Hangup": {"reason": "Normal", "call_id": null}}"#; + let decoded: SignalMessage = serde_json::from_str(json).unwrap(); + match decoded { + SignalMessage::Hangup { version, .. } => { + assert_eq!(version, 1, "missing version defaults to 1"); + } + _ => panic!("wrong variant"), + } + } + + #[test] + fn new_payload_with_version_deserializes() { + // Payload that explicitly includes version = 2. + let json = r#"{"Ping": {"version": 2, "timestamp_ms": 5678}}"#; + let decoded: SignalMessage = serde_json::from_str(json).unwrap(); + match decoded { + SignalMessage::Ping { + version, + timestamp_ms, + } => { + assert_eq!(version, 2, "explicit version is preserved"); + assert_eq!(timestamp_ms, 5678); } _ => panic!("wrong variant"), } diff --git a/crates/wzp-relay/src/federation.rs b/crates/wzp-relay/src/federation.rs index 27773b7..2e36b09 100644 --- a/crates/wzp-relay/src/federation.rs +++ b/crates/wzp-relay/src/federation.rs @@ -15,7 +15,7 @@ use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use tracing::{error, info, warn}; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; use wzp_transport::QuinnTransport; use crate::config::{PeerConfig, TrustedConfig}; @@ -520,7 +520,11 @@ async fn run_room_event_dispatcher( if fm.is_global_room(&room) { let participants = fm.room_mgr.local_participant_list(&room); info!(room = %room, count = participants.len(), "global room now active, announcing to peers"); - let msg = SignalMessage::GlobalRoomActive { room, participants }; + let msg = SignalMessage::GlobalRoomActive { + version: default_signal_version(), + room, + participants, + }; let transports: Vec> = { let links = fm.peer_links.lock().await; links.values().map(|l| l.transport.clone()).collect() @@ -533,7 +537,10 @@ async fn run_room_event_dispatcher( Ok(RoomEvent::LocalLeave { room }) => { if fm.is_global_room(&room) { info!(room = %room, "global room now inactive, announcing to peers"); - let msg = SignalMessage::GlobalRoomInactive { room }; + let msg = SignalMessage::GlobalRoomInactive { + version: default_signal_version(), + room, + }; let transports: Vec> = { let links = fm.peer_links.lock().await; links.values().map(|l| l.transport.clone()).collect() @@ -609,6 +616,7 @@ async fn run_stale_presence_sweeper(fm: Arc) { let mut seen = HashSet::new(); all_participants.retain(|p| seen.insert(p.fingerprint.clone())); let update = SignalMessage::RoomUpdate { + version: default_signal_version(), count: all_participants.len() as u32, participants: all_participants, }; @@ -659,6 +667,7 @@ async fn connect_to_peer( // Send hello with our TLS fingerprint let hello = SignalMessage::FederationHello { + version: default_signal_version(), tls_fingerprint: fm.local_tls_fp.clone(), }; transport @@ -710,6 +719,7 @@ async fn run_federation_link( let participants = fm.room_mgr.local_participant_list(room_name); info!(peer = %peer_label, room = %room_name, participants = participants.len(), "announcing local global room to new peer"); msgs.push(SignalMessage::GlobalRoomActive { + version: default_signal_version(), room: room_name.clone(), participants, }); @@ -724,6 +734,7 @@ async fn run_federation_link( if fm.is_global_room(room) { info!(peer = %peer_label, room = %room, via = %link.label, "propagating remote room to new peer"); msgs.push(SignalMessage::GlobalRoomActive { + version: default_signal_version(), room: room.clone(), participants: participants.clone(), }); @@ -837,7 +848,9 @@ async fn handle_signal( } match msg { - SignalMessage::GlobalRoomActive { room, participants } => { + SignalMessage::GlobalRoomActive { + room, participants, .. + } => { if fm.is_global_room(&room) { info!(peer = %peer_label, room = %room, remote_participants = participants.len(), "peer has global room active"); let mut links = fm.peer_links.lock().await; @@ -882,6 +895,7 @@ async fn handle_signal( let _ = link .transport .send_signal(&SignalMessage::GlobalRoomActive { + version: default_signal_version(), room: room.clone(), participants: tagged_for_propagation.clone(), }) @@ -923,6 +937,7 @@ async fn handle_signal( let mut seen = HashSet::new(); all_participants.retain(|p| seen.insert(p.fingerprint.clone())); let update = SignalMessage::RoomUpdate { + version: default_signal_version(), count: all_participants.len() as u32, participants: all_participants, }; @@ -933,7 +948,7 @@ async fn handle_signal( } } } - SignalMessage::GlobalRoomInactive { room } => { + SignalMessage::GlobalRoomInactive { room, .. } => { info!(peer = %peer_label, room = %room, "peer global room now inactive"); let mut links = fm.peer_links.lock().await; if let Some(link) = links.get_mut(peer_fp) { @@ -999,6 +1014,7 @@ async fn handle_signal( } } let msg = SignalMessage::GlobalRoomActive { + version: default_signal_version(), room: room.clone(), participants: updated_participants, }; @@ -1007,7 +1023,10 @@ async fn handle_signal( } } else { // No participants left anywhere — propagate inactive - let msg = SignalMessage::GlobalRoomInactive { room: room.clone() }; + let msg = SignalMessage::GlobalRoomInactive { + version: default_signal_version(), + room: room.clone(), + }; for transport in &peer_sends { let _ = transport.send_signal(&msg).await; } @@ -1025,6 +1044,7 @@ async fn handle_signal( let mut seen = HashSet::new(); all_participants.retain(|p| seen.insert(p.fingerprint.clone())); let update = SignalMessage::RoomUpdate { + version: default_signal_version(), count: all_participants.len() as u32, participants: all_participants, }; @@ -1050,6 +1070,7 @@ async fn handle_signal( SignalMessage::FederatedSignalForward { inner, origin_relay_fp, + .. } => { if origin_relay_fp == fm.local_tls_fp { tracing::debug!( diff --git a/crates/wzp-relay/src/handshake.rs b/crates/wzp-relay/src/handshake.rs index df1bcc1..e4e2379 100644 --- a/crates/wzp-relay/src/handshake.rs +++ b/crates/wzp-relay/src/handshake.rs @@ -4,7 +4,7 @@ //! recv `CallOffer` → verify → generate ephemeral → derive session → send `CallAnswer`. use wzp_crypto::{CryptoSession, KeyExchange, WarzoneKeyExchange}; -use wzp_proto::{MediaTransport, QualityProfile, SignalMessage}; +use wzp_proto::{MediaTransport, QualityProfile, SignalMessage, default_signal_version}; /// Accept the relay (callee) side of the cryptographic handshake. /// @@ -51,6 +51,7 @@ pub async fn accept_handshake( alias, protocol_version, supported_versions: _, + .. } => ( identity_pub, ephemeral_pub, @@ -70,6 +71,7 @@ pub async fn accept_handshake( // 1a. Protocol version check — we only speak v2. if protocol_version != 2 { let mismatch = SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::ProtocolVersionMismatch { server_supported: vec![2], }, @@ -108,6 +110,7 @@ pub async fn accept_handshake( // 6. Send CallAnswer let answer = SignalMessage::CallAnswer { + version: default_signal_version(), identity_pub, ephemeral_pub, signature, diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index 2435e88..333c9a6 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -16,7 +16,7 @@ use clap::Parser; use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; use wzp_relay::config::RelayConfig; use wzp_relay::metrics::RelayMetrics; use wzp_relay::pipeline::{PipelineConfig, RelayPipeline}; @@ -640,6 +640,7 @@ async fn main() -> anyhow::Result<()> { .send_to( &caller_fp, &SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }, @@ -685,6 +686,7 @@ async fn main() -> anyhow::Result<()> { // Emit the LOCAL CallSetup to our local caller. let setup = SignalMessage::CallSetup { + version: default_signal_version(), call_id: call_id.clone(), room: room_name.clone(), relay_addr: advertised_addr_d.clone(), @@ -703,7 +705,7 @@ async fn main() -> anyhow::Result<()> { ); } - SignalMessage::CallRinging { ref call_id } => { + SignalMessage::CallRinging { ref call_id, .. } => { // Forward to local caller for "ringing..." UX. let caller_fp = { let reg = call_registry_d.lock().await; @@ -866,9 +868,12 @@ async fn main() -> anyhow::Result<()> { info!(%addr, "probe connection detected, entering Ping/Pong + presence responder"); loop { match transport.recv_signal().await { - Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms })) => { + Ok(Some(wzp_proto::SignalMessage::Ping { timestamp_ms, .. })) => { if let Err(e) = transport - .send_signal(&wzp_proto::SignalMessage::Pong { timestamp_ms }) + .send_signal(&wzp_proto::SignalMessage::Pong { + version: default_signal_version(), + timestamp_ms, + }) .await { error!(%addr, "probe pong send error: {e}"); @@ -878,6 +883,7 @@ async fn main() -> anyhow::Result<()> { Ok(Some(wzp_proto::SignalMessage::PresenceUpdate { fingerprints, relay_addr, + .. })) => { // A peer relay is telling us which fingerprints it has let peer_addr: std::net::SocketAddr = @@ -894,6 +900,7 @@ async fn main() -> anyhow::Result<()> { reg.local_fingerprints().into_iter().collect() }; let reply = wzp_proto::SignalMessage::PresenceUpdate { + version: default_signal_version(), fingerprints: local_fps, relay_addr: addr.to_string(), }; @@ -902,7 +909,9 @@ async fn main() -> anyhow::Result<()> { break; } } - Ok(Some(wzp_proto::SignalMessage::RouteQuery { fingerprint, ttl })) => { + Ok(Some(wzp_proto::SignalMessage::RouteQuery { + fingerprint, ttl, .. + })) => { // Look up the fingerprint in our local registry let reg = presence.lock().await; let route = route_resolver.resolve(®, &fingerprint); @@ -930,6 +939,7 @@ async fn main() -> anyhow::Result<()> { }; let reply = wzp_proto::SignalMessage::RouteResponse { + version: default_signal_version(), fingerprint, found, relay_chain, @@ -968,6 +978,7 @@ async fn main() -> anyhow::Result<()> { { Ok(Ok(Some(wzp_proto::SignalMessage::FederationHello { tls_fingerprint, + .. }))) => tls_fingerprint, _ => { warn!(%addr, "federation: no hello received, closing"); @@ -1004,7 +1015,7 @@ async fn main() -> anyhow::Result<()> { // Optional auth let auth_fp: Option = if let Some(ref url) = auth_url { match transport.recv_signal().await { - Ok(Some(SignalMessage::AuthToken { token })) => { + Ok(Some(SignalMessage::AuthToken { token, .. })) => { match wzp_relay::auth::validate_token(url, &token).await { Ok(client) => Some(client.fingerprint), Err(e) => { @@ -1033,6 +1044,7 @@ async fn main() -> anyhow::Result<()> { identity_pub, signature: _, alias, + .. }))) => { // Compute fingerprint: SHA-256(Ed25519 pub key)[:16], same as Fingerprint type let fp = { @@ -1067,6 +1079,7 @@ async fn main() -> anyhow::Result<()> { // Send ack let _ = transport .send_signal(&SignalMessage::RegisterPresenceAck { + version: default_signal_version(), success: true, error: None, relay_build: Some(BUILD_GIT_HASH.to_string()), @@ -1126,6 +1139,7 @@ async fn main() -> anyhow::Result<()> { // federation has a matching entry. let forwarded = if let Some(ref fm) = federation_mgr { let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(msg.clone()), origin_relay_fp: tls_fp.clone(), }; @@ -1149,6 +1163,7 @@ async fn main() -> anyhow::Result<()> { info!(%addr, target = %target_fp, "call target not online (no federation route)"); let _ = transport .send_signal(&SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }) @@ -1193,6 +1208,7 @@ async fn main() -> anyhow::Result<()> { // federated delivery is in flight. let _ = transport .send_signal(&SignalMessage::CallRinging { + version: default_signal_version(), call_id: call_id.clone(), }) .await; @@ -1236,6 +1252,7 @@ async fn main() -> anyhow::Result<()> { drop(hub); let _ = transport .send_signal(&SignalMessage::CallRinging { + version: default_signal_version(), call_id: call_id.clone(), }) .await; @@ -1293,11 +1310,13 @@ async fn main() -> anyhow::Result<()> { if let Some(ref origin_fp) = peer_relay_fp { if let Some(ref fm) = federation_mgr { let hangup = SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: Some(call_id.clone()), }; let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(hangup), origin_relay_fp: tls_fp.clone(), }; @@ -1314,6 +1333,7 @@ async fn main() -> anyhow::Result<()> { .send_to( &peer_fp, &SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: Some(call_id.clone()), }, @@ -1390,6 +1410,7 @@ async fn main() -> anyhow::Result<()> { if let Some(ref fm) = federation_mgr { let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(msg.clone()), origin_relay_fp: tls_fp.clone(), }; @@ -1407,6 +1428,7 @@ async fn main() -> anyhow::Result<()> { } let setup_for_callee = SignalMessage::CallSetup { + version: default_signal_version(), call_id: call_id.clone(), room: room.clone(), relay_addr: relay_addr_for_setup, @@ -1429,6 +1451,7 @@ async fn main() -> anyhow::Result<()> { // cross-wired candidates (Phase 5.5 ICE // + Phase 8 port-mapped addrs). let setup_for_caller = SignalMessage::CallSetup { + version: default_signal_version(), call_id: call_id.clone(), room: room.clone(), relay_addr: relay_addr_for_setup.clone(), @@ -1437,6 +1460,7 @@ async fn main() -> anyhow::Result<()> { peer_mapped_addr: callee_mapped, }; let setup_for_callee = SignalMessage::CallSetup { + version: default_signal_version(), call_id: call_id.clone(), room: room.clone(), relay_addr: relay_addr_for_setup, @@ -1524,6 +1548,7 @@ async fn main() -> anyhow::Result<()> { if let Some(ref fm) = federation_mgr { let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(msg.clone()), origin_relay_fp: tls_fp.clone(), }; @@ -1568,6 +1593,7 @@ async fn main() -> anyhow::Result<()> { if let Some(ref fm) = federation_mgr { let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(msg.clone()), origin_relay_fp: tls_fp.clone(), }; @@ -1615,6 +1641,7 @@ async fn main() -> anyhow::Result<()> { if let Some(ref fm) = federation_mgr { let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(msg.clone()), origin_relay_fp: tls_fp.clone(), }; @@ -1629,9 +1656,12 @@ async fn main() -> anyhow::Result<()> { } } - SignalMessage::Ping { timestamp_ms } => { + SignalMessage::Ping { timestamp_ms, .. } => { let _ = transport - .send_signal(&SignalMessage::Pong { timestamp_ms }) + .send_signal(&SignalMessage::Pong { + version: default_signal_version(), + timestamp_ms, + }) .await; } @@ -1651,6 +1681,7 @@ async fn main() -> anyhow::Result<()> { let observed_addr = addr.to_string(); if let Err(e) = transport .send_signal(&SignalMessage::ReflectResponse { + version: default_signal_version(), observed_addr: observed_addr.clone(), }) .await @@ -1710,6 +1741,7 @@ async fn main() -> anyhow::Result<()> { .send_to( peer_fp, &SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: Some(call_id.clone()), }, @@ -1741,7 +1773,7 @@ async fn main() -> anyhow::Result<()> { let authenticated_fp: Option = if let Some(ref url) = auth_url { info!(%addr, "waiting for auth token..."); match transport.recv_signal().await { - Ok(Some(wzp_proto::SignalMessage::AuthToken { token })) => { + Ok(Some(wzp_proto::SignalMessage::AuthToken { token, .. })) => { match wzp_relay::auth::validate_token(url, &token).await { Ok(client) => { metrics.auth_attempts.with_label_values(&["ok"]).inc(); @@ -1913,6 +1945,7 @@ async fn main() -> anyhow::Result<()> { if let SignalMessage::RoomUpdate { count: _, participants: mut local_parts, + .. } = update { let remote = fm.get_remote_participants(&room_name).await; @@ -1921,6 +1954,7 @@ async fn main() -> anyhow::Result<()> { let mut seen = std::collections::HashSet::new(); local_parts.retain(|p| seen.insert(p.fingerprint.clone())); SignalMessage::RoomUpdate { + version: default_signal_version(), count: local_parts.len() as u32, participants: local_parts, } diff --git a/crates/wzp-relay/src/probe.rs b/crates/wzp-relay/src/probe.rs index cc37502..32b0b69 100644 --- a/crates/wzp-relay/src/probe.rs +++ b/crates/wzp-relay/src/probe.rs @@ -13,7 +13,7 @@ use prometheus::{Gauge, IntGauge, Opts, Registry}; use tokio::sync::Mutex; use tracing::{error, info, warn}; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; /// Configuration for a single probe target. #[derive(Clone, Debug)] @@ -229,7 +229,7 @@ impl ProbeRunner { let recv_handle = tokio::spawn(async move { loop { match recv_transport.recv_signal().await { - Ok(Some(SignalMessage::Pong { timestamp_ms })) => { + Ok(Some(SignalMessage::Pong { timestamp_ms, .. })) => { let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -244,6 +244,7 @@ impl ProbeRunner { Ok(Some(SignalMessage::PresenceUpdate { fingerprints, relay_addr, + .. })) => { if let Some(ref reg) = recv_presence { // Parse the relay_addr; fall back to the connection target @@ -293,7 +294,10 @@ impl ProbeRunner { } if let Err(e) = transport - .send_signal(&SignalMessage::Ping { timestamp_ms }) + .send_signal(&SignalMessage::Ping { + version: default_signal_version(), + timestamp_ms, + }) .await { error!(target = %self.config.target, "probe ping send error: {e}"); @@ -310,6 +314,7 @@ impl ProbeRunner { r.local_fingerprints().into_iter().collect() }; let msg = SignalMessage::PresenceUpdate { + version: default_signal_version(), fingerprints: fps, relay_addr: self.config.target.to_string(), }; @@ -426,9 +431,10 @@ pub fn mesh_summary(registry: &Registry) -> String { /// Handle an incoming Ping signal by replying with a Pong carrying the same timestamp. /// Returns true if the message was a Ping and was handled, false otherwise. pub async fn handle_ping(transport: &wzp_transport::QuinnTransport, msg: &SignalMessage) -> bool { - if let SignalMessage::Ping { timestamp_ms } = msg { + if let SignalMessage::Ping { timestamp_ms, .. } = msg { if let Err(e) = transport .send_signal(&SignalMessage::Pong { + version: default_signal_version(), timestamp_ms: *timestamp_ms, }) .await diff --git a/crates/wzp-relay/src/relay_link.rs b/crates/wzp-relay/src/relay_link.rs index 60366cd..2b1830e 100644 --- a/crates/wzp-relay/src/relay_link.rs +++ b/crates/wzp-relay/src/relay_link.rs @@ -333,10 +333,11 @@ mod tests { #[test] fn session_forward_signal_roundtrip() { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; // SessionForward roundtrip let msg = SignalMessage::SessionForward { + version: default_signal_version(), session_id: "abcd1234".to_string(), target_fingerprint: "deadbeef".to_string(), source_relay: "10.0.0.1:4433".to_string(), @@ -348,6 +349,7 @@ mod tests { session_id, target_fingerprint, source_relay, + .. } => { assert_eq!(session_id, "abcd1234"); assert_eq!(target_fingerprint, "deadbeef"); @@ -358,6 +360,7 @@ mod tests { // SessionForwardAck roundtrip let ack = SignalMessage::SessionForwardAck { + version: default_signal_version(), session_id: "abcd1234".to_string(), room_name: "relay-room-42".to_string(), }; @@ -367,6 +370,7 @@ mod tests { SignalMessage::SessionForwardAck { session_id, room_name, + .. } => { assert_eq!(session_id, "abcd1234"); assert_eq!(room_name, "relay-room-42"); diff --git a/crates/wzp-relay/src/room.rs b/crates/wzp-relay/src/room.rs index f7b45e5..67638ba 100644 --- a/crates/wzp-relay/src/room.rs +++ b/crates/wzp-relay/src/room.rs @@ -13,10 +13,10 @@ use bytes::Bytes; use dashmap::DashMap; use tracing::{error, info, warn}; -use wzp_proto::MediaTransport; use wzp_proto::packet::TrunkFrame; use wzp_proto::quality::{AdaptiveQualityController, Tier}; use wzp_proto::traits::QualityController; +use wzp_proto::{MediaTransport, default_signal_version}; use crate::conformance::ConformanceMeter; use crate::metrics::RelayMetrics; @@ -64,6 +64,7 @@ impl DebugTap { wzp_proto::SignalMessage::RoomUpdate { count, participants, + .. } => { let names: Vec<&str> = participants .iter() @@ -81,6 +82,7 @@ impl DebugTap { wzp_proto::SignalMessage::QualityDirective { recommended_profile, reason, + .. } => { info!( target: "debug_tap", @@ -493,6 +495,7 @@ impl RoomManager { ); room.qualities.insert(id, ParticipantQuality::new()); let update = wzp_proto::SignalMessage::RoomUpdate { + version: default_signal_version(), count: room.len() as u32, participants: room.participant_list(), }; @@ -570,6 +573,7 @@ impl RoomManager { return None; } let update = wzp_proto::SignalMessage::RoomUpdate { + version: default_signal_version(), count: room.len() as u32, participants: room.participant_list(), }; @@ -654,6 +658,7 @@ impl RoomManager { ); let directive = wzp_proto::SignalMessage::QualityDirective { + version: default_signal_version(), recommended_profile: profile, reason: Some(format!("weakest link: {weakest:?}")), }; diff --git a/crates/wzp-relay/src/route.rs b/crates/wzp-relay/src/route.rs index dc86986..30f7919 100644 --- a/crates/wzp-relay/src/route.rs +++ b/crates/wzp-relay/src/route.rs @@ -201,9 +201,10 @@ mod tests { #[test] fn route_query_signal_roundtrip() { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; let query = SignalMessage::RouteQuery { + version: default_signal_version(), fingerprint: "aabbccdd".to_string(), ttl: 3, }; @@ -211,11 +212,12 @@ mod tests { let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); assert!(matches!( decoded, - SignalMessage::RouteQuery { ref fingerprint, ttl } + SignalMessage::RouteQuery { ref fingerprint, ttl, ..} if fingerprint == "aabbccdd" && ttl == 3 )); let response = SignalMessage::RouteResponse { + version: default_signal_version(), fingerprint: "aabbccdd".to_string(), found: true, relay_chain: vec!["10.0.0.1:4433".to_string(), "10.0.0.2:4433".to_string()], @@ -224,7 +226,7 @@ mod tests { let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); assert!(matches!( decoded, - SignalMessage::RouteResponse { ref fingerprint, found, ref relay_chain } + SignalMessage::RouteResponse { ref fingerprint, found, ref relay_chain, ..} if fingerprint == "aabbccdd" && found && relay_chain.len() == 2 )); } diff --git a/crates/wzp-relay/src/signal_hub.rs b/crates/wzp-relay/src/signal_hub.rs index e2a8bf0..0891552 100644 --- a/crates/wzp-relay/src/signal_hub.rs +++ b/crates/wzp-relay/src/signal_hub.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::time::Instant; use tracing::info; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; use wzp_transport::QuinnTransport; /// A client connected via `_signal` for direct calling. @@ -101,7 +101,10 @@ impl SignalHub { alias: c.alias.clone(), }) .collect(); - SignalMessage::PresenceList { users } + SignalMessage::PresenceList { + version: default_signal_version(), + users, + } } /// Broadcast a message to ALL connected signal clients. diff --git a/crates/wzp-relay/tests/cross_relay_direct_call.rs b/crates/wzp-relay/tests/cross_relay_direct_call.rs index ad0ff05..f52f15c 100644 --- a/crates/wzp-relay/tests/cross_relay_direct_call.rs +++ b/crates/wzp-relay/tests/cross_relay_direct_call.rs @@ -24,7 +24,7 @@ //! Bob's CallSetup carries Alice's reflex addr — cross-wired //! through two relays + a federation link. -use wzp_proto::{CallAcceptMode, SignalMessage}; +use wzp_proto::{CallAcceptMode, SignalMessage, default_signal_version}; use wzp_relay::call_registry::CallRegistry; // ──────────────────────────────────────────────────────────────── @@ -42,6 +42,7 @@ const RELAY_B_ADDR: &str = "203.0.113.10:4433"; /// Helper that Alice's place_call sends. fn alice_offer(call_id: &str) -> SignalMessage { SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: "alice".into(), caller_alias: None, target_fingerprint: "bob".into(), @@ -84,6 +85,7 @@ fn relay_a_handle_offer(reg_a: &mut CallRegistry, offer: &SignalMessage) -> Sign // Build the federation envelope the main loop would // broadcast. SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(offer.clone()), origin_relay_fp: RELAY_A_TLS_FP.into(), } @@ -97,6 +99,7 @@ fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMess SignalMessage::FederatedSignalForward { inner, origin_relay_fp, + .. } => (inner.as_ref().clone(), origin_relay_fp.clone()), _ => panic!("not a forward"), }; @@ -123,6 +126,7 @@ fn relay_b_handle_forwarded_offer(reg_b: &mut CallRegistry, forward: &SignalMess /// Bob's answer — AcceptTrusted with his reflex addr. fn bob_answer(call_id: &str) -> SignalMessage { SignalMessage::DirectCallAnswer { + version: default_signal_version(), call_id: call_id.into(), accept_mode: CallAcceptMode::AcceptTrusted, identity_pub: None, @@ -166,12 +170,14 @@ fn relay_b_handle_local_answer( // Forward the answer back over federation. let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(answer.clone()), origin_relay_fp: RELAY_B_TLS_FP.into(), }; // Local CallSetup for Bob — peer_direct_addr = Alice's addr. let setup_for_bob = SignalMessage::CallSetup { + version: default_signal_version(), call_id: call_id.clone(), room: format!("call-{call_id}"), relay_addr: RELAY_B_ADDR.into(), @@ -194,6 +200,7 @@ fn relay_a_handle_forwarded_answer( SignalMessage::FederatedSignalForward { inner, origin_relay_fp, + .. } => (inner.as_ref().clone(), origin_relay_fp.clone()), _ => panic!("not a forward"), }; @@ -215,6 +222,7 @@ fn relay_a_handle_forwarded_answer( // Alice's CallSetup — peer_direct_addr = Bob's addr. SignalMessage::CallSetup { + version: default_signal_version(), call_id: call_id.clone(), room: format!("call-{call_id}"), relay_addr: RELAY_A_ADDR.into(), @@ -312,6 +320,7 @@ fn cross_relay_loop_prevention_drops_self_sourced_forward() { // A FederatedSignalForward that circles back to the origin // relay should be dropped before it hits the call registry. let forward = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(alice_offer("c-loop")), origin_relay_fp: RELAY_B_TLS_FP.into(), }; diff --git a/crates/wzp-relay/tests/federation.rs b/crates/wzp-relay/tests/federation.rs index 0b8ff33..4f82584 100644 --- a/crates/wzp-relay/tests/federation.rs +++ b/crates/wzp-relay/tests/federation.rs @@ -18,7 +18,7 @@ use std::sync::Arc; use std::time::Duration; use bytes::Bytes; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; use wzp_relay::config::{PeerConfig, TrustedConfig}; use wzp_relay::event_log::EventLogger; use wzp_relay::federation::{FederationManager, room_hash}; @@ -348,7 +348,9 @@ async fn broadcast_signal_sends_to_all_peers() { .expect("some message"); match hello { - SignalMessage::FederationHello { tls_fingerprint } => { + SignalMessage::FederationHello { + tls_fingerprint, .. + } => { assert_eq!(tls_fingerprint, "test-relay-fp-abc123"); } other => panic!( @@ -367,6 +369,7 @@ async fn broadcast_signal_sends_to_all_peers() { // Now call broadcast_signal on the FM let test_msg = SignalMessage::FederatedSignalForward { + version: default_signal_version(), inner: Box::new(SignalMessage::Reflect), origin_relay_fp: "other-relay-fp".into(), }; diff --git a/crates/wzp-relay/tests/handshake_integration.rs b/crates/wzp-relay/tests/handshake_integration.rs index c809390..5fd69be 100644 --- a/crates/wzp-relay/tests/handshake_integration.rs +++ b/crates/wzp-relay/tests/handshake_integration.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use wzp_client::perform_handshake; use wzp_crypto::{KeyExchange, WarzoneKeyExchange}; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; use wzp_relay::handshake::accept_handshake; use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config}; @@ -129,6 +129,7 @@ async fn handshake_rejects_v1_protocol_version() { let signature = kx.sign(&sign_data); let v1_offer = SignalMessage::CallOffer { + version: 1, identity_pub, ephemeral_pub, signature, @@ -255,7 +256,7 @@ async fn auth_then_handshake() { .expect("should receive a message"); let token = match auth_msg { - SignalMessage::AuthToken { token } => token, + SignalMessage::AuthToken { token, .. } => token, other => panic!( "expected AuthToken, got {:?}", std::mem::discriminant(&other) @@ -273,6 +274,7 @@ async fn auth_then_handshake() { // Caller side: send AuthToken first, then perform_handshake. let auth = SignalMessage::AuthToken { + version: default_signal_version(), token: "bearer-test-token-12345".to_string(), }; client_transport @@ -344,6 +346,7 @@ async fn handshake_rejects_bad_signature() { } let bad_offer = SignalMessage::CallOffer { + version: default_signal_version(), identity_pub, ephemeral_pub, signature, diff --git a/crates/wzp-relay/tests/hole_punching.rs b/crates/wzp-relay/tests/hole_punching.rs index 49a5062..9f489f6 100644 --- a/crates/wzp-relay/tests/hole_punching.rs +++ b/crates/wzp-relay/tests/hole_punching.rs @@ -20,7 +20,7 @@ //! to reason about, no real network, and what we actually want to //! test is the cross-wiring logic, not the whole signal stack. -use wzp_proto::{CallAcceptMode, SignalMessage}; +use wzp_proto::{CallAcceptMode, SignalMessage, default_signal_version}; use wzp_relay::call_registry::CallRegistry; /// Helper: simulate the relay's handling of a DirectCallOffer. In @@ -77,6 +77,7 @@ fn handle_answer_and_build_setups( }; let setup_for_caller = SignalMessage::CallSetup { + version: default_signal_version(), call_id: call_id.clone(), room: room.clone(), relay_addr: "203.0.113.5:4433".into(), @@ -85,6 +86,7 @@ fn handle_answer_and_build_setups( peer_mapped_addr: None, }; let setup_for_callee = SignalMessage::CallSetup { + version: default_signal_version(), call_id, room, relay_addr: "203.0.113.5:4433".into(), @@ -97,6 +99,7 @@ fn handle_answer_and_build_setups( fn mk_offer(call_id: &str, caller_reflexive_addr: Option<&str>) -> SignalMessage { SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: "alice".into(), caller_alias: None, target_fingerprint: "bob".into(), @@ -118,6 +121,7 @@ fn mk_answer( callee_reflexive_addr: Option<&str>, ) -> SignalMessage { SignalMessage::DirectCallAnswer { + version: default_signal_version(), call_id: call_id.into(), accept_mode: mode, identity_pub: None, diff --git a/crates/wzp-relay/tests/multi_reflect.rs b/crates/wzp-relay/tests/multi_reflect.rs index 6c0e78a..39ca27f 100644 --- a/crates/wzp-relay/tests/multi_reflect.rs +++ b/crates/wzp-relay/tests/multi_reflect.rs @@ -63,6 +63,7 @@ async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) { Ok(Some(SignalMessage::RegisterPresence { .. })) => { let _ = t .send_signal(&SignalMessage::RegisterPresenceAck { + version: 1, success: true, error: None, relay_build: None, @@ -74,6 +75,7 @@ async fn spawn_mock_relay() -> (SocketAddr, tokio::task::JoinHandle<()>) { Ok(Some(SignalMessage::Reflect)) => { let _ = t .send_signal(&SignalMessage::ReflectResponse { + version: 1, observed_addr: observed_addr.to_string(), }) .await; diff --git a/crates/wzp-relay/tests/reflect.rs b/crates/wzp-relay/tests/reflect.rs index e785520..bbf43d8 100644 --- a/crates/wzp-relay/tests/reflect.rs +++ b/crates/wzp-relay/tests/reflect.rs @@ -30,7 +30,7 @@ use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; use std::time::Duration; -use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_proto::{MediaTransport, SignalMessage, default_signal_version}; use wzp_transport::{QuinnTransport, client_config, create_endpoint, server_config}; /// Spawn a minimal mock relay that loops over `recv_signal`, @@ -49,6 +49,7 @@ async fn spawn_mock_relay_with_reflect( match server_transport.recv_signal().await { Ok(Some(SignalMessage::Reflect)) => { let resp = SignalMessage::ReflectResponse { + version: default_signal_version(), observed_addr: observed.to_string(), }; // If the send fails the client has gone; just exit. @@ -164,7 +165,7 @@ async fn reflect_happy_path() { .expect("some message"); let observed_addr = match resp { - SignalMessage::ReflectResponse { observed_addr } => observed_addr, + SignalMessage::ReflectResponse { observed_addr, .. } => observed_addr, other => panic!( "expected ReflectResponse, got {:?}", std::mem::discriminant(&other) @@ -251,7 +252,7 @@ async fn reflect_two_clients_distinct_ports() { .expect("ok") .expect("some"); match resp { - SignalMessage::ReflectResponse { observed_addr } => observed_addr, + SignalMessage::ReflectResponse { observed_addr, .. } => observed_addr, _ => panic!("wrong variant"), } }; diff --git a/crates/wzp-web/src/main.rs b/crates/wzp-web/src/main.rs index 54c4bff..9a4bfd3 100644 --- a/crates/wzp-web/src/main.rs +++ b/crates/wzp-web/src/main.rs @@ -22,7 +22,7 @@ use tower_http::services::ServeDir; use tracing::{error, info, warn}; use wzp_client::call::{CallConfig, CallDecoder, CallEncoder}; -use wzp_proto::MediaTransport; +use wzp_proto::{MediaTransport, default_signal_version}; mod metrics; use metrics::WebMetrics; @@ -297,6 +297,7 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) { // Send auth token to relay (if auth is enabled) if let Some(ref token) = browser_token { let auth = wzp_proto::SignalMessage::AuthToken { + version: default_signal_version(), token: token.clone(), }; if let Err(e) = transport.send_signal(&auth).await { diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs index d96f77b..6b068d3 100644 --- a/desktop/src-tauri/src/engine.rs +++ b/desktop/src-tauri/src/engine.rs @@ -168,6 +168,7 @@ async fn run_signal_task( Ok(Ok(Some(wzp_proto::SignalMessage::QualityDirective { recommended_profile, reason, + .. }))) => { let idx = profile_to_index(&recommended_profile); info!( diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3bc478d..e4dbd6d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -35,7 +35,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, OnceLock}; use tauri::{Emitter, Manager}; use tokio::sync::Mutex; -use wzp_proto::MediaTransport; +use wzp_proto::{MediaTransport, default_signal_version}; // ─── Call-flow debug logs (GUI-gated) ──────────────────────────────── // @@ -615,6 +615,7 @@ async fn connect( // Send our report if let Some(ref t) = transport_for_report { let report = wzp_proto::SignalMessage::MediaPathReport { + version: default_signal_version(), call_id: call_id_for_report.clone(), direct_ok: local_direct_ok, race_winner: format!("{:?}", local_winner), @@ -1247,7 +1248,7 @@ fn do_register_signal( relay: String, ) -> impl std::future::Future> + Send { async move { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; emit_call_debug( &app, @@ -1316,6 +1317,7 @@ fn do_register_signal( let alias = derive_alias(&seed); transport .send_signal(&SignalMessage::RegisterPresence { + version: default_signal_version(), identity_pub, signature: vec![], alias: Some(alias), @@ -1375,7 +1377,7 @@ fn do_register_signal( let signal_state = signal_state_loop.clone(); loop { match transport.recv_signal().await { - Ok(Some(SignalMessage::CallRinging { call_id })) => { + Ok(Some(SignalMessage::CallRinging { call_id, .. })) => { tracing::info!(%call_id, "signal: CallRinging"); emit_call_debug( &app_clone, @@ -1453,6 +1455,7 @@ fn do_register_signal( peer_direct_addr, peer_local_addrs, peer_mapped_addr, + .. })) => { // Phase 3: peer_direct_addr carries the OTHER party's // reflex addr. Phase 5.5: peer_local_addrs carries @@ -1512,6 +1515,7 @@ fn do_register_signal( call_id, direct_ok, race_winner, + .. })) => { // Phase 6: the peer is telling us whether // their direct path succeeded. Fire the @@ -1543,6 +1547,7 @@ fn do_register_signal( local_addrs, mapped_addr, generation, + .. })) => { // Phase 8: peer re-gathered candidates after a // network change. Emit to JS for UI notification @@ -1586,6 +1591,7 @@ fn do_register_signal( allocation, probe_time_ms, external_ip, + .. })) => { tracing::info!( %call_id, @@ -1647,6 +1653,7 @@ fn do_register_signal( let _ = t .send_signal( &wzp_proto::SignalMessage::HardNatBirthdayStart { + version: default_signal_version(), call_id: call_id_bg, acceptor_port_count: result.succeeded, acceptor_ports: ext_ports, @@ -1661,7 +1668,7 @@ fn do_register_signal( }); } } - Ok(Some(SignalMessage::PresenceList { users })) => { + Ok(Some(SignalMessage::PresenceList { users, .. })) => { tracing::info!(count = users.len(), "signal: PresenceList received"); // Emit to JS frontend for lobby user list let user_list: Vec = users @@ -1687,6 +1694,7 @@ fn do_register_signal( proposed_profile, local_loss_pct, local_rtt_ms, + .. })) => { tracing::info!(%call_id, %proposal_id, ?proposed_profile, "signal: UpgradeProposal from peer"); emit_call_debug( @@ -1706,6 +1714,7 @@ fn do_register_signal( proposal_id, accepted, reason, + .. })) => { tracing::info!(%call_id, %proposal_id, accepted, ?reason, "signal: UpgradeResponse from peer"); emit_call_debug( @@ -1722,6 +1731,7 @@ fn do_register_signal( call_id, proposal_id, confirmed_profile, + .. })) => { tracing::info!(%call_id, %proposal_id, ?confirmed_profile, "signal: UpgradeConfirm"); emit_call_debug( @@ -1739,6 +1749,7 @@ fn do_register_signal( max_profile, loss_pct, rtt_ms, + .. })) => { tracing::info!(%call_id, ?max_profile, "signal: QualityCapability from peer"); emit_call_debug( @@ -1758,6 +1769,7 @@ fn do_register_signal( acceptor_port_count, acceptor_ports, external_ip, + .. })) => { tracing::info!( %call_id, @@ -1786,7 +1798,7 @@ fn do_register_signal( }); } } - Ok(Some(SignalMessage::ReflectResponse { observed_addr })) => { + Ok(Some(SignalMessage::ReflectResponse { observed_addr, .. })) => { // "STUN for QUIC" response — the relay told us our // own server-reflexive address. If a Tauri command // is currently awaiting this, fire the oneshot; @@ -2009,7 +2021,7 @@ async fn place_call( app: tauri::AppHandle, target_fp: String, ) -> Result<(), String> { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; emit_call_debug( &app, @@ -2140,6 +2152,7 @@ async fn place_call( tracing::info!(%call_id, %target_fp, reflex = ?own_reflex, mapped = ?caller_mapped_addr, "place_call: sending DirectCallOffer"); transport .send_signal(&SignalMessage::DirectCallOffer { + version: default_signal_version(), caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp.clone(), @@ -2199,6 +2212,7 @@ async fn place_call( if let Some(ref t) = sig.transport { let _ = t .send_signal(&SignalMessage::HardNatProbe { + version: default_signal_version(), call_id: call_id_bg, port_sequence: result.observed_ports, allocation: alloc_str, @@ -2228,7 +2242,7 @@ async fn answer_call( call_id: String, mode: i32, ) -> Result<(), String> { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; let accept_mode = match mode { 0 => wzp_proto::CallAcceptMode::Reject, 1 => wzp_proto::CallAcceptMode::AcceptTrusted, @@ -2375,6 +2389,7 @@ async fn answer_call( tracing::info!(%call_id, ?accept_mode, reflex = ?own_reflex, mapped = ?callee_mapped_addr, "answer_call: sending DirectCallAnswer"); transport .send_signal(&SignalMessage::DirectCallAnswer { + version: default_signal_version(), call_id: call_id.clone(), accept_mode, identity_pub: None, @@ -2437,6 +2452,7 @@ async fn answer_call( if let Some(ref t) = sig.transport { let _ = t .send_signal(&wzp_proto::SignalMessage::HardNatProbe { + version: default_signal_version(), call_id: call_id_bg, port_sequence: result.observed_ports, allocation: alloc_str, @@ -2475,7 +2491,7 @@ async fn answer_call( /// or temporarily unreachable for reflect but the call can still /// proceed with STUN-discovered addresses. async fn try_reflect_own_addr(state: &Arc) -> Result, String> { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; let (tx, rx) = tokio::sync::oneshot::channel::(); let transport = { let mut sig = state.signal.lock().await; @@ -2562,7 +2578,7 @@ async fn try_stun_fallback(state: &Arc) -> Result, Stri /// with `new URL(...)` / a regex if needed. #[tauri::command] async fn get_reflected_address(state: tauri::State<'_, Arc>) -> Result { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; let (tx, rx) = tokio::sync::oneshot::channel::(); let transport = { let mut sig = state.signal.lock().await; @@ -2766,7 +2782,7 @@ async fn hangup_call( state: tauri::State<'_, Arc>, app: tauri::AppHandle, ) -> Result<(), String> { - use wzp_proto::SignalMessage; + use wzp_proto::{SignalMessage, default_signal_version}; emit_call_debug(&app, "hangup_call:start", serde_json::json!({})); @@ -2778,6 +2794,7 @@ async fn hangup_call( if let Some(ref transport) = sig.transport { match transport .send_signal(&SignalMessage::Hangup { + version: default_signal_version(), reason: wzp_proto::HangupReason::Normal, call_id: None, }) diff --git a/docs/PRD/TASKS.md b/docs/PRD/TASKS.md index 5429aec..2280d6d 100644 --- a/docs/PRD/TASKS.md +++ b/docs/PRD/TASKS.md @@ -1321,7 +1321,7 @@ Statuses (in order of progression): | T2.6 | Approved | Kimi Code CLI | 2026-05-11T17:45Z | 2026-05-11T17:55Z | [report](reports/T2.6-report.md) | Substance good (Prom metrics); bundled in 54c1a35. Consolidated reviewer notes here. | | T3.1 | Approved | Kimi Code CLI | 2026-05-11T20:55Z | 2026-05-11T21:05Z | [report](reports/T3.1-report.md) | Approved. DashMap>>; W13 resolved. One commit per task this time — good. Two minor process notes in report. | | T3.2 | Committed | Kimi Code CLI | 2026-05-11T21:15Z | 2026-05-11T21:25Z | [report](reports/T3.2-report.md) | timestamp_ms monotonic across rekey; doc + test. | -| T3.3 | Open | — | — | — | — | — | +| T3.3 | Pending Review | Kimi Code CLI | 2026-05-11T16:29Z | 2026-05-11T16:29Z | [report](reports/T3.3-report.md) | — | | T3.4 | Open | — | — | — | — | — | | T3.5 | Open | — | — | — | — | — | | T4.1 | Open | — | — | — | — | Skeleton — expand before claiming | @@ -1350,5 +1350,6 @@ Items currently waiting on the reviewer: - T1.8 — Per-stream anti-replay window with configurable size — report: reports/T1.8-report.md - T2.1 — Add `SignalMessage::TransportFeedback` — report: reports/T2.1-report.md - T2.2 — `BandwidthEstimator` in `wzp-proto::bandwidth` — report: reports/T2.2-report.md +- T3.3 — SignalMessage version field — report: reports/T3.3-report.md Once a task moves to `Pending Review`, add a line here so the reviewer sees it: `- T — report: reports/T-report.md`. The reviewer removes the line when they mark it `Approved` (or moves it back to the agent on `Changes Requested`). diff --git a/docs/PRD/reports/T3.3-report.md b/docs/PRD/reports/T3.3-report.md new file mode 100644 index 0000000..f9bd588 --- /dev/null +++ b/docs/PRD/reports/T3.3-report.md @@ -0,0 +1,100 @@ +# T3.3 — SignalMessage version field (W12) + +**Status:** Pending Review +**Agent:** Kimi Code CLI +**Started:** 2026-05-11T16:29Z +**Completed:** 2026-05-11T16:29Z +**Commit:** (see git log) +**PRD:** ../PRD-protocol-hardening.md + +## What I changed + +- `crates/wzp-proto/src/packet.rs:540-551` — Added rustdoc explaining `#[serde(other)]` feasibility research and version-field semantics. +- `crates/wzp-proto/src/packet.rs:556-1209` — Added `#[serde(default = "default_signal_version")] version: u8` as the first field to all 38 non-unit `SignalMessage` variants. +- `crates/wzp-proto/src/packet.rs:1217-1220` — Added `pub fn default_signal_version() -> u8 { 1 }`. +- `crates/wzp-proto/src/packet.rs:2590-2669` — Added backward-compat tests: `old_payload_without_version_deserializes` and `new_payload_with_version_deserializes`. +- `crates/wzp-proto/src/lib.rs:32-37` — Re-exported `default_signal_version`. +- `crates/wzp-client/src/handshake.rs`, `crates/wzp-client/src/cli.rs`, `crates/wzp-client/src/ice_agent.rs`, `crates/wzp-client/src/reflect.rs`, `crates/wzp-client/src/analyzer.rs`, `crates/wzp-client/src/featherchat.rs`, `crates/wzp-client/tests/handshake_integration.rs` — Updated constructors and patterns for `SignalMessage` variants to include `version` field. +- `crates/wzp-relay/src/main.rs`, `crates/wzp-relay/src/federation.rs`, `crates/wzp-relay/src/handshake.rs`, `crates/wzp-relay/src/probe.rs`, `crates/wzp-relay/src/relay_link.rs`, `crates/wzp-relay/src/room.rs`, `crates/wzp-relay/src/route.rs`, `crates/wzp-relay/src/signal_hub.rs` — Updated constructors and patterns for `SignalMessage` variants. +- `crates/wzp-relay/tests/cross_relay_direct_call.rs`, `crates/wzp-relay/tests/federation.rs`, `crates/wzp-relay/tests/handshake_integration.rs`, `crates/wzp-relay/tests/hole_punching.rs`, `crates/wzp-relay/tests/multi_reflect.rs`, `crates/wzp-relay/tests/reflect.rs` — Updated test constructors and patterns. +- `crates/wzp-android/src/engine.rs` — Updated constructors and patterns. +- `crates/wzp-web/src/main.rs` — Updated import ordering (cargo fmt). +- `crates/wzp-crypto/tests/featherchat_compat.rs` — Updated import ordering (cargo fmt). +- `desktop/src-tauri/src/engine.rs`, `desktop/src-tauri/src/lib.rs` — Updated patterns and constructors. + +## Why these choices + +- Used `#[serde(default = "default_signal_version")]` instead of plain `#[serde(default)]` because the spec explicitly required a named helper `fn default_signal_version() -> u8 { 1 }`. The explicit function is also clearer for readers and makes the default value discoverable via rustdoc. +- Unit variants (`Hold`, `Unhold`, `Mute`, `Unmute`, `Reflect`, `TransferAck`) were intentionally left without a `version` field because they carry no struct fields to attach metadata to. Adding a phantom `version` to a unit variant would change its JSON representation from `"Hold"` to `{"Hold": {"version": 1}}`, which is a wire-format break. +- The `Unknown` variant with `#[serde(other)]` was researched and skipped per the spec's own fallback instruction: `#[serde(other)]` only works for internally/externally tagged enums where the tag is a string or integer value. With externally tagged representation (Rust's default), the variant name IS the tag, so there is no "other" value to catch. `bincode` also does not support `#[serde(other)]`. This limitation is documented in the `SignalMessage` rustdoc. +- Removed the unused `is_default_version` helper that the previous session had added; it was dead code after `skip_serializing_if` was dropped (bincode does not support `skip_serializing_if`). + +## Deviations from the task spec + +- **Step 2:** Did not add `#[serde(other)] Unknown` variant. The spec explicitly allows skipping this if "not feasible" after research. Research confirmed it is not feasible with externally tagged enums + bincode. The limitation is documented in the `SignalMessage` rustdoc. +- **Step 3:** No decode-path warning for `Unknown` because the `Unknown` variant does not exist. Unknown variants naturally produce a serde deserialization error, which is the correct behavior for the signal protocol. + +## Verification output + +``` +$ cargo test -p wzp-proto --lib +running 121 tests +... +test result: ok. 121 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s +``` + +``` +$ cargo test -p wzp-proto -- transport_feedback +running 2 tests +test packet::tests::transport_feedback_default_version ... ok +test packet::tests::transport_feedback_roundtrip ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 119 filtered out; finished in 0.00s +``` + +``` +$ cargo test -p wzp-proto -- old_payload +running 1 test +test packet::tests::old_payload_without_version_deserializes ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 120 filtered out; finished in 0.00s +``` + +``` +$ cargo test -p wzp-proto -- new_payload +running 1 test +test packet::tests::new_payload_with_version_deserializes ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 120 filtered out; finished in 0.00s +``` + +``` +$ cargo test --workspace --exclude wzp-android --no-fail-fast +... (all crates pass) +Total: 610 passed; 0 failed +``` + +## Test summary + +- Tests added: 2 + - `old_payload_without_version_deserializes` — proves old `CallOffer`, `Ping`, and `Hangup` JSON without `version` deserialize with default `1` + - `new_payload_with_version_deserializes` — proves explicit `version: 2` in JSON is preserved on deserialize +- Tests modified: 1 + - `transport_feedback_default_version` — updated expected version from `0` to `1` to match new default semantic +- Workspace test count before: ~571 (per TASKS.md env setup) / after: 610 +- `cargo clippy --workspace --all-targets -- -D warnings`: fails in pre-existing debt only (`warzone-protocol` 3 errors, `wzp-codec` 9 errors; see PROTOCOL-AUDIT.md). Crate touched by this task (`wzp-proto`) is clean. +- `cargo fmt --all -- --check`: pass + +## Risks / follow-ups + +- **T3.2 status corruption:** The status board shows T3.2 as `Committed`, which is not a valid workflow status. Per the agent instructions, I did not touch already-reviewed tasks. The reviewer should flip T3.2 to `Approved` (its actual status from prior review). +- Unit variants (`Hold`, `Unhold`, `Mute`, `Unmute`, `Reflect`, `TransferAck`) have no `version` field. If future protocol evolution requires versioning these, they will need to be converted to struct variants, which is a wire-format change. +- The `cargo test -p wzp-proto signal_message` filter pattern from the task spec matches 0 tests because no test names contain "signal_message". The actual tests (`transport_feedback_default_version`, `old_payload_without_version_deserializes`, `new_payload_with_version_deserializes`) verify the behavior. + +## Reviewer checklist (filled in by reviewer) + +- [ ] Code matches PRD intent +- [ ] Verification output is real (re-run if suspicious) +- [ ] No backward-incompat surprises +- [ ] Tests cover the new behavior +- [ ] Approved