From 4b688b23fb5ec9581a984c262958a0742835e8f3 Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Wed, 6 May 2026 19:59:28 +0000 Subject: [PATCH 1/3] serviceability: remove InterfaceV3 entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V3 was added by an earlier change, never written to production accounts, and reverted in #3653. After #3667 stopped producing V3 (always-V2 projection of the legacy slot) and #3675 deleted the migrate processor, the type is dead weight. This removes it as if it never existed — no read shim, since no on-chain account ever held discriminant 3. - Delete InterfaceV3 struct + Default/From/TryFrom impls. - Delete InterfaceDeprecated::V3 variant; discriminant 3 is now an unused reserved slot. Unknown discriminants fall through to InterfaceV2::default(). - Drop V3 match arms in InterfaceDeprecated::to_v2 / size and in Device::TryFrom legacy-rebuild. - Drop V3 from Go/Python/TS SDKs: remove DeserializeInterfaceV3, version==3 / version === 3 branches, and the V3 cross-language Go test. - Delete the V3 cross-language byte-layout debug test in interface.rs and the V3 block in test_interface_version. On-disk write format unchanged. --- CHANGELOG.md | 5 + .../python/serviceability/state.py | 23 +- .../typescript/serviceability/state.ts | 22 +- .../src/state/device.rs | 7 +- .../src/state/interface.rs | 294 +----------------- .../sdk/go/serviceability/bytereader_test.go | 104 ------- .../sdk/go/serviceability/deserialize.go | 27 +- smartcontract/sdk/go/serviceability/state.go | 6 +- 8 files changed, 34 insertions(+), 454 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a057d6b6..6ecfe06de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to this project will be documented in this file. ### Changes +- Smartcontract + - Delete `InterfaceV3` and the `InterfaceDeprecated::V3` variant from the serviceability program. V3 was added by an earlier change, never written to production accounts, and reverted in #3653 / no longer produced after #3667; this removes the dead type. Discriminant 3 is now an unused reserved slot in `InterfaceDeprecated`'s encoding space — unknown discriminants fall through to `InterfaceV2::default()`. Removes the V3 struct, its helper impls (`From`, `TryFrom<&InterfaceV1>`, `Default`, `TryFrom<&InterfaceV3> for InterfaceV2`), V3 match arms in `InterfaceDeprecated::to_v2`/`size`/`Device::TryFrom`, and the V3 cross-language byte-layout debug test. On-disk write format is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) +- SDK + - Drop V3 handling from the Go, Python, and TypeScript serviceability readers: remove `DeserializeInterfaceV3` (Go) and the `version === 3` / `version == 3` legacy-slot branches (Python/TS); remove the `TestDeserializeInterfaceV3CrossLanguage` Go test. The forward-compat trailing `interfaces` vec continues to carry `flex_algo_node_segments` via the size-prefixed body — that path is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) + ## [v0.22.0](https://github.com/malbeclabs/doublezero/compare/client/v0.21.0...client/v0.22.0) - 2026-05-08 ### Breaking diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 521cf483e6..9ddc39d6b7 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -404,9 +404,9 @@ def __str__(self) -> str: # On-wire schema version for the size-prefixed Interface format # (matches Rust's CURRENT_INTERFACE_SCHEMA_VERSION). Note: prior to issue #3660 # this constant gated the legacy enum reader at value 2 (max known disc=1); it -# is now bumped to 4 to match the size-prefixed schema. Legacy enum reads still -# only handle version 0 (V1) and 1 (V2); version 3 (V3) accounts fall through -# to a default Interface (pre-existing gap). +# is now bumped to 4 to match the size-prefixed schema. Legacy enum reads only +# handle version 0 (V1) and 1 (V2); discriminant 3 was a transient V3 format +# that never reached production and is no longer recognized. CURRENT_INTERFACE_VERSION = 4 @@ -445,8 +445,8 @@ def from_reader(cls, r: DefensiveReader) -> Interface: iface.version = r.read_u8() if iface.version > CURRENT_INTERFACE_VERSION - 1: return iface - # Discriminants: 0=V1, 1 or 2=V2 (no flex_algo_node_segments), - # 3=V3 (V2 fields + flex_algo_node_segments). + # Discriminants: 0=V1, 1 or 2=V2. Discriminant 3 was V3 (transient, + # never shipped) and is intentionally unhandled. if iface.version == 0: iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() @@ -456,7 +456,7 @@ def from_reader(cls, r: DefensiveReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() - elif iface.version in (1, 2, 3): + elif iface.version in (1, 2): iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() iface.interface_type = InterfaceType(r.read_u8()) @@ -471,13 +471,6 @@ def from_reader(cls, r: DefensiveReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() - if iface.version == 3: - count = r.read_u32() - for _ in range(count): - seg = FlexAlgoNodeSegment() - seg.topology = _read_pubkey(r) - seg.node_segment_idx = r.read_u16() - iface.flex_algo_node_segments.append(seg) return iface @classmethod @@ -493,8 +486,8 @@ def from_reader_sized(cls, r: DefensiveReader) -> Interface: iface.size = r.read_u16() iface.version = r.read_u8() - # Body fields (current schema, version 4): same order as InterfaceV2 + - # the flex_algo_node_segments vec from V3. + # Body fields (current schema, version 4): same order as InterfaceV2, + # plus a trailing flex_algo_node_segments vec. iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() iface.interface_type = InterfaceType(r.read_u8()) diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 85cdb93ed6..bd74066aff 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -530,8 +530,8 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { return iface; } - // Discriminants: 0=V1, 1 or 2=V2 (no flex_algo_node_segments), - // 3=V3 (V2 fields + flex_algo_node_segments). + // Discriminants: 0=V1, 1 or 2=V2. Discriminant 3 was V3 (transient, + // never shipped) and is intentionally unhandled. if (iface.version === 0) { iface.status = r.readU8(); iface.name = r.readString(); @@ -541,7 +541,7 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - } else if (iface.version === 1 || iface.version === 2 || iface.version === 3) { + } else if (iface.version === 1 || iface.version === 2) { iface.status = r.readU8(); iface.name = r.readString(); iface.interfaceType = r.readU8(); @@ -556,18 +556,6 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - if (iface.version === 3) { - const segCount = r.readU32(); - const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; - for (let i = 0; i < segCount; i++) { - if (r.remaining < 34) break; // 32 (pubkey) + 2 (u16) - flexAlgoNodeSegments.push({ - topology: readPubkey(r), - nodeSegmentIdx: r.readU16(), - }); - } - iface.flexAlgoNodeSegments = flexAlgoNodeSegments; - } } return iface; @@ -583,8 +571,8 @@ function deserializeInterfaceSized(r: DefensiveReader): DeviceInterface { const size = r.readU16(); const version = r.readU8(); - // Body fields (current schema, version 4): same order as InterfaceV2 + the - // flex_algo_node_segments vec from V3. + // Body fields (current schema, version 4): same order as InterfaceV2, plus + // a trailing flex_algo_node_segments vec. const status = r.readU8(); const name = r.readString(); const interfaceType = r.readU8(); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/device.rs b/smartcontract/programs/doublezero-serviceability/src/state/device.rs index d02e7a10e6..ddd30b5fc5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/device.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/device.rs @@ -604,18 +604,13 @@ impl TryFrom<&[u8]> for Device { let interfaces = if trailing.is_empty() { // Legacy account: rebuild from the legacy enum vec via per-variant - // `TryFrom`. V3 is projected through V2, dropping `flex_algo_node_segments` - // (V3 only exists from migrate/backfill paths post-#3653). + // `TryFrom`. deprecated_interfaces .iter() .map(|iface| -> Result { match iface { InterfaceDeprecated::V1(v1) => v1.try_into(), InterfaceDeprecated::V2(v2) => v2.try_into(), - InterfaceDeprecated::V3(v3) => { - let v2: InterfaceV2 = v3.try_into()?; - (&v2).try_into() - } } }) .collect::, _>>()? diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index c271a155d3..c6138f8fd7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -373,29 +373,6 @@ impl TryFrom<&InterfaceV1> for InterfaceV2 { } } -impl TryFrom<&InterfaceV3> for InterfaceV2 { - type Error = ProgramError; - - fn try_from(data: &InterfaceV3) -> Result { - Ok(Self { - status: data.status, - name: data.name.clone(), - interface_type: data.interface_type, - interface_cyoa: data.interface_cyoa, - interface_dia: data.interface_dia, - loopback_type: data.loopback_type, - bandwidth: data.bandwidth, - cir: data.cir, - mtu: data.mtu, - routing_mode: data.routing_mode, - vlan_id: data.vlan_id, - ip_net: data.ip_net, - node_segment_idx: data.node_segment_idx, - user_tunnel_endpoint: data.user_tunnel_endpoint, - }) - } -} - impl Default for InterfaceV2 { fn default() -> Self { Self { @@ -417,77 +394,6 @@ impl Default for InterfaceV2 { } } -#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct InterfaceV3 { - pub status: InterfaceStatus, - pub name: String, - pub interface_type: InterfaceType, - pub interface_cyoa: InterfaceCYOA, - pub interface_dia: InterfaceDIA, - pub loopback_type: LoopbackType, - pub bandwidth: u64, - pub cir: u64, - pub mtu: u16, - pub routing_mode: RoutingMode, - pub vlan_id: u16, - pub ip_net: NetworkV4, - pub node_segment_idx: u16, - pub user_tunnel_endpoint: bool, - pub flex_algo_node_segments: Vec, -} - -impl InterfaceV3 { - pub fn size(&self) -> usize { - Self::size_given_name_len(self.name.len()) - } - - pub fn to_interface(&self) -> InterfaceDeprecated { - InterfaceDeprecated::V3(self.clone()) - } - - pub fn size_given_name_len(name_len: usize) -> usize { - 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 4 // +4 for empty flex_algo_node_segments vec (Borsh length prefix) - } -} - -impl From for InterfaceV3 { - fn from(v2: InterfaceV2) -> Self { - Self { - status: v2.status, - name: v2.name, - interface_type: v2.interface_type, - interface_cyoa: v2.interface_cyoa, - interface_dia: v2.interface_dia, - loopback_type: v2.loopback_type, - bandwidth: v2.bandwidth, - cir: v2.cir, - mtu: v2.mtu, - routing_mode: v2.routing_mode, - vlan_id: v2.vlan_id, - ip_net: v2.ip_net, - node_segment_idx: v2.node_segment_idx, - user_tunnel_endpoint: v2.user_tunnel_endpoint, - flex_algo_node_segments: vec![], - } - } -} - -impl TryFrom<&InterfaceV1> for InterfaceV3 { - type Error = ProgramError; - - fn try_from(data: &InterfaceV1) -> Result { - let v2: InterfaceV2 = data.try_into()?; - Ok(v2.into()) - } -} - -impl Default for InterfaceV3 { - fn default() -> Self { - InterfaceV2::default().into() - } -} - #[repr(u8)] #[derive(BorshSerialize, Debug, PartialEq, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -495,10 +401,10 @@ impl Default for InterfaceV3 { pub enum InterfaceDeprecated { V1(InterfaceV1) = 0, /// Discriminant 1: V2 format. Does NOT include flex_algo_node_segments. + /// Discriminant 2 is reserved (never written). Discriminant 3 was a transient + /// V3 format that never reached production and has been removed; the slot is + /// reserved and unused. V2(InterfaceV2) = 1, - /// Discriminant 3: V3 format. Includes flex_algo_node_segments (RFC-18). - /// Discriminant 2 is intentionally skipped (reserved). - V3(InterfaceV3) = 3, } impl borsh::BorshDeserialize for InterfaceDeprecated { @@ -511,23 +417,19 @@ impl borsh::BorshDeserialize for InterfaceDeprecated { 1 | 2 => Ok(InterfaceDeprecated::V2( borsh::BorshDeserialize::deserialize_reader(reader)?, )), - 3 => Ok(InterfaceDeprecated::V3( - borsh::BorshDeserialize::deserialize_reader(reader)?, - )), - _ => Ok(InterfaceDeprecated::V3(InterfaceV3::default())), + _ => Ok(InterfaceDeprecated::V2(InterfaceV2::default())), } } } impl InterfaceDeprecated { - /// Convert any legacy variant to its V2 projection. V1 and V3 fan in via - /// `TryFrom<&InterfaceVN>` for `InterfaceV2`; conversion failures fall back + /// Convert any legacy variant to its V2 projection. V1 fans in via + /// `TryFrom<&InterfaceV1>` for `InterfaceV2`; conversion failures fall back /// to `InterfaceV2::default()`. pub fn to_v2(&self) -> InterfaceV2 { match self { InterfaceDeprecated::V1(v1) => v1.try_into().unwrap_or_default(), InterfaceDeprecated::V2(v2) => v2.clone(), - InterfaceDeprecated::V3(v3) => v3.try_into().unwrap_or_default(), } } @@ -535,7 +437,6 @@ impl InterfaceDeprecated { let base_size = match self { InterfaceDeprecated::V1(v1) => v1.size(), InterfaceDeprecated::V2(v2) => v2.size(), - InterfaceDeprecated::V3(v3) => v3.size(), }; base_size + 1 // +1 for the enum discriminant } @@ -872,38 +773,6 @@ fn test_interface_version() { assert_eq!(iface_v2.ip_net, "10.0.0.0/24".parse().unwrap()); assert_eq!(iface_v2.node_segment_idx, 200); assert!(iface_v2.user_tunnel_endpoint); - - let iface = InterfaceV3 { - status: InterfaceStatus::Activated, - name: "Loopback0".to_string(), - interface_type: InterfaceType::Loopback, - interface_cyoa: InterfaceCYOA::GREOverDIA, - interface_dia: InterfaceDIA::DIA, - loopback_type: LoopbackType::Ipv4, - bandwidth: 1000, - cir: 500, - mtu: 1500, - routing_mode: RoutingMode::BGP, - vlan_id: 100, - ip_net: "10.0.0.0/24".parse().unwrap(), - node_segment_idx: 200, - user_tunnel_endpoint: true, - flex_algo_node_segments: vec![], - } - .to_interface(); - - assert!( - matches!(iface, InterfaceDeprecated::V3(_)), - "iface is not InterfaceDeprecated::V3" - ); - let iface_v3 = iface.to_v2(); - assert_eq!(iface_v3.name, "Loopback0"); - assert_eq!(iface_v3.interface_type, InterfaceType::Loopback); - assert_eq!(iface_v3.loopback_type, LoopbackType::Ipv4); - assert_eq!(iface_v3.vlan_id, 100); - assert_eq!(iface_v3.ip_net, "10.0.0.0/24".parse().unwrap()); - assert_eq!(iface_v3.node_segment_idx, 200); - assert!(iface_v3.user_tunnel_endpoint); } #[cfg(test)] @@ -1011,157 +880,6 @@ mod test_interface_validate { let err = InterfaceDeprecated::V2(iface).validate(); assert_eq!(err.unwrap_err(), DoubleZeroError::InvalidInterfaceIp); } - - /// Test that prints serialized bytes of InterfaceV3 for cross-language debugging. - /// Run with: cargo test test_interface_v3_serialization_bytes -- --nocapture - #[test] - fn test_interface_v3_serialization_bytes() { - // Create an interface similar to what the e2e test creates after update - let iface = InterfaceV3 { - status: InterfaceStatus::Activated, - name: "Loopback106".to_string(), - interface_type: InterfaceType::Loopback, - interface_cyoa: InterfaceCYOA::None, - interface_dia: InterfaceDIA::None, - loopback_type: LoopbackType::Ipv4, // Updated value - bandwidth: 0, - cir: 0, - mtu: 9000, // Updated value - routing_mode: RoutingMode::Static, - vlan_id: 0, - ip_net: "203.0.113.40/32".parse().unwrap(), - node_segment_idx: 0, - user_tunnel_endpoint: true, - flex_algo_node_segments: vec![], - }; - - // Serialize as InterfaceDeprecated::V3 (with enum discriminant) - let interface_enum = InterfaceDeprecated::V3(iface.clone()); - let bytes = borsh::to_vec(&interface_enum).unwrap(); - - println!("\n=== InterfaceV3 Serialization Debug ==="); - println!("Total bytes: {}", bytes.len()); - println!("Hex: {:02x?}", bytes); - println!("\nField breakdown:"); - println!(" [0] enum discriminant (V3=3): {:02x}", bytes[0]); - - let mut offset = 1; - println!(" [{}] status (Activated=1): {:02x}", offset, bytes[offset]); - offset += 1; - - // String: 4 bytes length + chars - let name_len = u32::from_le_bytes([ - bytes[offset], - bytes[offset + 1], - bytes[offset + 2], - bytes[offset + 3], - ]); - println!( - " [{}-{}] name length: {} (0x{:08x})", - offset, - offset + 3, - name_len, - name_len - ); - offset += 4; - let name_bytes = &bytes[offset..offset + name_len as usize]; - println!( - " [{}-{}] name: {:?}", - offset, - offset + name_len as usize - 1, - String::from_utf8_lossy(name_bytes) - ); - offset += name_len as usize; - - println!( - " [{}] interface_type (Loopback=2): {:02x}", - offset, bytes[offset] - ); - offset += 1; - println!( - " [{}] interface_cyoa (None=0): {:02x}", - offset, bytes[offset] - ); - offset += 1; - println!( - " [{}] interface_dia (None=0): {:02x}", - offset, bytes[offset] - ); - offset += 1; - println!( - " [{}] loopback_type (Ipv4=1): {:02x}", - offset, bytes[offset] - ); - offset += 1; - - let bandwidth = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); - println!( - " [{}-{}] bandwidth: {} (0x{:016x})", - offset, - offset + 7, - bandwidth, - bandwidth - ); - offset += 8; - - let cir = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); - println!( - " [{}-{}] cir: {} (0x{:016x})", - offset, - offset + 7, - cir, - cir - ); - offset += 8; - - let mtu = u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()); - println!(" [{}-{}] mtu: {} (0x{:04x})", offset, offset + 1, mtu, mtu); - offset += 2; - - println!( - " [{}] routing_mode (Static=0): {:02x}", - offset, bytes[offset] - ); - offset += 1; - - let vlan_id = u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()); - println!( - " [{}-{}] vlan_id: {} (0x{:04x})", - offset, - offset + 1, - vlan_id, - vlan_id - ); - offset += 2; - - println!( - " [{}-{}] ip_net: {:02x?}", - offset, - offset + 4, - &bytes[offset..offset + 5] - ); - offset += 5; - - let node_segment_idx = u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()); - println!( - " [{}-{}] node_segment_idx: {} (0x{:04x})", - offset, - offset + 1, - node_segment_idx, - node_segment_idx - ); - offset += 2; - - println!(" [{}] user_tunnel_endpoint: {:02x}", offset, bytes[offset]); - offset += 1; - - println!(" Total parsed: {} bytes", offset); - println!("=====================================\n"); - - // Verify the serialization - assert_eq!(mtu, 9000); - assert_eq!(bytes[0], 3); // V3 discriminant - } } #[cfg(test)] diff --git a/smartcontract/sdk/go/serviceability/bytereader_test.go b/smartcontract/sdk/go/serviceability/bytereader_test.go index 428ace76a5..b52a9e91d8 100644 --- a/smartcontract/sdk/go/serviceability/bytereader_test.go +++ b/smartcontract/sdk/go/serviceability/bytereader_test.go @@ -2,7 +2,6 @@ package serviceability import ( "encoding/binary" - "encoding/hex" "reflect" "testing" ) @@ -249,109 +248,6 @@ func TestReadString(t *testing.T) { } } -// TestDeserializeInterfaceV2CrossLanguage tests deserializing bytes that Rust produces. -// To get the expected bytes, run in Rust: -// -// cargo test test_interface_v2_serialization_bytes -- --nocapture -// -// Then copy the hex output here. -func TestDeserializeInterfaceV3CrossLanguage(t *testing.T) { - t.Parallel() - - // These bytes are ACTUAL output from Rust test (RFC-18, V3 with flex_algo_node_segments): - // Hex: [03, 03, 0b, 00, 00, 00, 4c, 6f, 6f, 70, 62, 61, 63, 6b, 31, 30, 36, 01, 00, 00, 02, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 28, 23, 00, 00, 00, cb, 00, 71, 28, 20, 00, 00, 01, 00, 00, 00, 00] - // - // Field breakdown from Rust: - // [0] enum discriminant (V3=3): 03 - // [1] status (Activated=3): 03 - // [2-5] name length: 11 (0x0000000b) - // [6-16] name: "Loopback106" - // [17] interface_type (Loopback=1): 01 - // [18] interface_cyoa (None=0): 00 - // [19] interface_dia (None=0): 00 - // [20] loopback_type (Ipv4=2): 02 - // [21-28] bandwidth: 0 - // [29-36] cir: 0 - // [37-38] mtu: 9000 (0x2328) - // [39] routing_mode (Static=0): 00 - // [40-41] vlan_id: 0 - // [42-46] ip_net: [cb, 00, 71, 28, 20] = 203.0.113.40/32 - // [47-48] node_segment_idx: 0 - // [49] user_tunnel_endpoint: 01 (true) - // [50-53] flex_algo_node_segments length: 0 (empty vec) - - // Use EXACT bytes from Rust serialization - data := []byte{ - 0x03, // [0] enum discriminant V3=3 - 0x03, // [1] status Activated=3 - 0x0b, 0x00, 0x00, 0x00, // [2-5] name length = 11 - 0x4c, 0x6f, 0x6f, 0x70, 0x62, 0x61, 0x63, 0x6b, 0x31, 0x30, 0x36, // [6-16] "Loopback106" - 0x01, // [17] interface_type Loopback=1 - 0x00, // [18] interface_cyoa None=0 - 0x00, // [19] interface_dia None=0 - 0x02, // [20] loopback_type Ipv4=2 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // [21-28] bandwidth=0 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // [29-36] cir=0 - 0x28, 0x23, // [37-38] mtu=9000 - 0x00, // [39] routing_mode Static=0 - 0x00, 0x00, // [40-41] vlan_id=0 - 0xcb, 0x00, 0x71, 0x28, 0x20, // [42-46] ip_net 203.0.113.40/32 - 0x00, 0x00, // [47-48] node_segment_idx=0 - 0x01, // [49] user_tunnel_endpoint=true - 0x00, 0x00, 0x00, 0x00, // [50-53] flex_algo_node_segments length=0 (empty vec, RFC-18) - } - - t.Logf("Test data (%d bytes): %s", len(data), hex.EncodeToString(data)) - - reader := NewByteReader(data) - var iface Interface - DeserializeInterface(reader, &iface) - - t.Logf("Deserialized interface:") - t.Logf(" Version: %d", iface.Version) - t.Logf(" Status: %d", iface.Status) - t.Logf(" Name: %s", iface.Name) - t.Logf(" InterfaceType: %d", iface.InterfaceType) - t.Logf(" InterfaceCYOA: %d", iface.InterfaceCYOA) - t.Logf(" InterfaceDIA: %d", iface.InterfaceDIA) - t.Logf(" LoopbackType: %d", iface.LoopbackType) - t.Logf(" Bandwidth: %d", iface.Bandwidth) - t.Logf(" Cir: %d", iface.Cir) - t.Logf(" Mtu: %d", iface.Mtu) - t.Logf(" RoutingMode: %d", iface.RoutingMode) - t.Logf(" VlanId: %d", iface.VlanId) - t.Logf(" IpNet: %v", iface.IpNet) - t.Logf(" NodeSegmentIdx: %d", iface.NodeSegmentIdx) - t.Logf(" UserTunnelEndpoint: %v", iface.UserTunnelEndpoint) - t.Logf(" Remaining bytes: %d", reader.Remaining()) - - // Assertions - if iface.Version != 3 { - t.Errorf("Version: got %d, expected 3 (V3 enum discriminant)", iface.Version) - } - if iface.Status != InterfaceStatusActivated { - t.Errorf("Status: got %d, expected %d (Activated)", iface.Status, InterfaceStatusActivated) - } - if iface.Name != "Loopback106" { - t.Errorf("Name: got %s, expected Loopback106", iface.Name) - } - if iface.InterfaceType != InterfaceTypeLoopback { - t.Errorf("InterfaceType: got %d, expected %d (Loopback)", iface.InterfaceType, InterfaceTypeLoopback) - } - if iface.LoopbackType != LoopbackTypeIpv4 { - t.Errorf("LoopbackType: got %d, expected %d (Ipv4)", iface.LoopbackType, LoopbackTypeIpv4) - } - if iface.Mtu != 9000 { - t.Errorf("Mtu: got %d, expected 9000", iface.Mtu) - } - if !iface.UserTunnelEndpoint { - t.Errorf("UserTunnelEndpoint: got %v, expected true", iface.UserTunnelEndpoint) - } - if reader.Remaining() != 0 { - t.Errorf("Should have consumed all bytes, but %d remaining", reader.Remaining()) - } -} - // TestDeserializeResourceExtensionIdAllocator tests deserializing a ResourceExtension with ID allocator func TestDeserializeResourceExtensionIdAllocator(t *testing.T) { t.Parallel() diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index f07fc4f3de..f1f5b7dab5 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -88,24 +88,20 @@ func DeserializeContributor(reader *ByteReader, contributor *Contributor) { // Interface version history (discriminant byte): // // 0 — V1: original format (no CYOA/DIA/Bandwidth fields) -// 1 — V2: adds CYOA, DIA, Bandwidth, Cir, Mtu, RoutingMode (no flex_algo_node_segments) +// 1 — V2: adds CYOA, DIA, Bandwidth, Cir, Mtu, RoutingMode // 2 — reserved, never written -// 3 — V3: V2 fields + flex_algo_node_segments (RFC-18) // // The on-chain Device serializer projects the legacy deprecated_interfaces slot as V2 -// (per #3653); discriminant-3 entries seen here are residual from older -// accounts. Newer flex_algo_node_segments data lives in the trailing -// interfaces vec on Device, not in this slot. +// (per #3653). flex_algo_node_segments lives only in the trailing forward-compat +// interfaces vec on Device (read via DeserializeInterfaceSized), not in this slot. func DeserializeInterface(reader *ByteReader, iface *Interface) { iface.Version = reader.ReadU8() switch iface.Version { case 0: // V1 DeserializeInterfaceV1(reader, iface) - case 1, 2: // V2: no flex_algo_node_segments + case 1, 2: // V2 DeserializeInterfaceV2(reader, iface) - case 3: // V3: includes flex_algo_node_segments (RFC-18) - DeserializeInterfaceV3(reader, iface) default: log.Println("DeserializeInterface: Unsupported interface version", iface.Version) } @@ -139,17 +135,6 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.UserTunnelEndpoint = (reader.ReadU8() != 0) } -func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { - DeserializeInterfaceV2(reader, iface) - // flex_algo_node_segments (RFC-18): present in all V3 accounts. - length := reader.ReadU32() - iface.FlexAlgoNodeSegments = make([]FlexAlgoNodeSegment, length) - for i := uint32(0); i < length; i++ { - iface.FlexAlgoNodeSegments[i].Topology = reader.ReadPubkey() - iface.FlexAlgoNodeSegments[i].NodeSegmentIdx = reader.ReadU16() - } -} - // DeserializeInterfaceSized reads a single size-prefixed Interface element. // // Wire format: u16 size + u8 version + body, where size includes the 3-byte prefix. @@ -160,8 +145,8 @@ func DeserializeInterfaceSized(reader *ByteReader, iface *Interface) { iface.Size = reader.ReadU16() iface.Version = reader.ReadU8() - // Body fields (current schema, version 4): same order as InterfaceV2 + the - // flex_algo_node_segments vec from V3. + // Body fields (current schema, version 4): same order as InterfaceV2, plus a + // trailing flex_algo_node_segments vec. iface.Status = InterfaceStatus(reader.ReadU8()) iface.Name = reader.ReadString() iface.InterfaceType = InterfaceType(reader.ReadU8()) diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index d8de6b745c..e3cb81cd7a 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -415,9 +415,9 @@ type Interface struct { NodeSegmentIdx uint16 UserTunnelEndpoint bool // FlexAlgoNodeSegments holds flex-algo node segment assignments for this interface (RFC-18). - // Only populated when reading a discriminant-3 (V3) interface; the on-chain Device serializer - // now projects the deprecated_interfaces slot as V2 (per #3653) and surfaces segments through - // the trailing interfaces vec on Device. Nil otherwise. + // Populated only on entries read from the trailing forward-compat interfaces vec on Device + // (via DeserializeInterfaceSized). The legacy deprecated_interfaces slot does not carry + // segments; nil there. FlexAlgoNodeSegments []FlexAlgoNodeSegment `json:",omitempty"` } From 446a487e065594af7bdffc76238d2535cdd9b68a Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Thu, 7 May 2026 04:30:08 +0000 Subject: [PATCH 2/3] serviceability: read legacy V3 bytes as V2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mainnet-beta has one device (la2r-dz01, interface Ethernet20/1) with a discriminant-3 (V3) entry in its legacy deprecated_interfaces slot, written by an admin path that no longer exists. The previous commit removed all V3 read handling, which caused byte misalignment on that account: the V3 body (V2 fields + flex_algo_node_segments vec) went unread, the cursor desynced, and downstream Device fields decoded as garbage. CI sdk-compat-test caught this against mainnet: "110 is not a valid DeviceHealth". Restore byte-level consumption of discriminant 3 in all readers (Rust + Go/Python/TS SDKs): read the V2 body and consume + drop the trailing flex_algo_node_segments vec, then surface the entry as V2. The InterfaceV3 struct stays deleted; only the wire-compat read path is restored. Segments live in the trailing forward-compat interfaces vec on Device post-#3667 — this slot loses them, matching the always-V2 projection on writes. Adds a Rust regression test that hand-crafts a Vec with a V3-encoded element followed by a V2 element and asserts the V3 bytes are fully consumed, so the trailing V2 still decodes — guards against the misalignment that broke CI. --- CHANGELOG.md | 4 +- .../python/serviceability/state.py | 13 +++- .../typescript/serviceability/state.ts | 16 +++- .../src/state/interface.rs | 78 ++++++++++++++++++- .../sdk/go/serviceability/deserialize.go | 11 +++ 5 files changed, 112 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ecfe06de3..919dbbcac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ All notable changes to this project will be documented in this file. ### Changes - Smartcontract - - Delete `InterfaceV3` and the `InterfaceDeprecated::V3` variant from the serviceability program. V3 was added by an earlier change, never written to production accounts, and reverted in #3653 / no longer produced after #3667; this removes the dead type. Discriminant 3 is now an unused reserved slot in `InterfaceDeprecated`'s encoding space — unknown discriminants fall through to `InterfaceV2::default()`. Removes the V3 struct, its helper impls (`From`, `TryFrom<&InterfaceV1>`, `Default`, `TryFrom<&InterfaceV3> for InterfaceV2`), V3 match arms in `InterfaceDeprecated::to_v2`/`size`/`Device::TryFrom`, and the V3 cross-language byte-layout debug test. On-disk write format is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) + - Delete `InterfaceV3` and the `InterfaceDeprecated::V3` variant from the serviceability program. The V3 type, its helper impls (`From`, `TryFrom<&InterfaceV1>`, `Default`, `TryFrom<&InterfaceV3> for InterfaceV2`), the V3 match arms in `InterfaceDeprecated::to_v2`/`size`/`Device::TryFrom`, and the V3 cross-language byte-layout debug test are removed. The custom `BorshDeserialize for InterfaceDeprecated` keeps a discriminant-3 read shim that consumes the V2 body + the trailing `flex_algo_node_segments` vec and surfaces the entry as `InterfaceDeprecated::V2` — required because mainnet-beta still has one device (`la2r-dz01`, interface `Ethernet20/1`) with a V3 entry in its legacy slot. Segments are dropped on this read path; they live in the trailing forward-compat `Device::interfaces` vec post-#3667. On-disk write format is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) - SDK - - Drop V3 handling from the Go, Python, and TypeScript serviceability readers: remove `DeserializeInterfaceV3` (Go) and the `version === 3` / `version == 3` legacy-slot branches (Python/TS); remove the `TestDeserializeInterfaceV3CrossLanguage` Go test. The forward-compat trailing `interfaces` vec continues to carry `flex_algo_node_segments` via the size-prefixed body — that path is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) + - Same legacy-V3 read shim in the Go, Python, and TypeScript serviceability readers: discriminant-3 entries in the legacy `deprecated_interfaces` slot are read as V2 (V2 body + consumed-and-dropped segments vec). The forward-compat trailing `interfaces` vec continues to carry `flex_algo_node_segments` via the size-prefixed body — that path is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) ## [v0.22.0](https://github.com/malbeclabs/doublezero/compare/client/v0.21.0...client/v0.22.0) - 2026-05-08 diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 9ddc39d6b7..84ef3056c5 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -445,8 +445,10 @@ def from_reader(cls, r: DefensiveReader) -> Interface: iface.version = r.read_u8() if iface.version > CURRENT_INTERFACE_VERSION - 1: return iface - # Discriminants: 0=V1, 1 or 2=V2. Discriminant 3 was V3 (transient, - # never shipped) and is intentionally unhandled. + # Discriminants: 0=V1, 1 or 2=V2. Discriminant 3 was a transient V3 + # format (V2 body + flex_algo_node_segments vec); the type is gone but + # pre-existing on-chain accounts still contain V3 entries, so we consume + # the bytes and project to V2 (segments dropped). if iface.version == 0: iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() @@ -456,7 +458,7 @@ def from_reader(cls, r: DefensiveReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() - elif iface.version in (1, 2): + elif iface.version in (1, 2, 3): iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() iface.interface_type = InterfaceType(r.read_u8()) @@ -471,6 +473,11 @@ def from_reader(cls, r: DefensiveReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() + if iface.version == 3: + seg_count = r.read_u32() + for _ in range(seg_count): + _read_pubkey(r) + r.read_u16() return iface @classmethod diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index bd74066aff..9c28910156 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -530,8 +530,10 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { return iface; } - // Discriminants: 0=V1, 1 or 2=V2. Discriminant 3 was V3 (transient, - // never shipped) and is intentionally unhandled. + // Discriminants: 0=V1, 1 or 2=V2. Discriminant 3 was a transient V3 format + // (V2 body + flex_algo_node_segments vec); the type is gone but pre-existing + // on-chain accounts still contain V3 entries, so we consume the bytes and + // project to V2 (segments dropped). if (iface.version === 0) { iface.status = r.readU8(); iface.name = r.readString(); @@ -541,7 +543,7 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - } else if (iface.version === 1 || iface.version === 2) { + } else if (iface.version === 1 || iface.version === 2 || iface.version === 3) { iface.status = r.readU8(); iface.name = r.readString(); iface.interfaceType = r.readU8(); @@ -556,6 +558,14 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); + if (iface.version === 3) { + const segCount = r.readU32(); + for (let i = 0; i < segCount; i++) { + if (r.remaining < 34) break; + readPubkey(r); + r.readU16(); + } + } } return iface; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index c6138f8fd7..4951f6d739 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -402,8 +402,10 @@ pub enum InterfaceDeprecated { V1(InterfaceV1) = 0, /// Discriminant 1: V2 format. Does NOT include flex_algo_node_segments. /// Discriminant 2 is reserved (never written). Discriminant 3 was a transient - /// V3 format that never reached production and has been removed; the slot is - /// reserved and unused. + /// V3 format (V2 body + a `flex_algo_node_segments` vec) that no longer has + /// a corresponding type — the deserializer still consumes V3 bytes from + /// pre-existing on-chain accounts and projects them to V2 (segments dropped), + /// but nothing writes V3 going forward. V2(InterfaceV2) = 1, } @@ -417,6 +419,16 @@ impl borsh::BorshDeserialize for InterfaceDeprecated { 1 | 2 => Ok(InterfaceDeprecated::V2( borsh::BorshDeserialize::deserialize_reader(reader)?, )), + // Discriminant 3 (legacy V3): consume V2 body + the trailing + // flex_algo_node_segments vec, then surface as V2. The segments are + // dropped — they live in the trailing forward-compat `interfaces` vec + // on Device post-#3667. + 3 => { + let v2: InterfaceV2 = borsh::BorshDeserialize::deserialize_reader(reader)?; + let _segments: Vec = + borsh::BorshDeserialize::deserialize_reader(reader)?; + Ok(InterfaceDeprecated::V2(v2)) + } _ => Ok(InterfaceDeprecated::V2(InterfaceV2::default())), } } @@ -775,6 +787,68 @@ fn test_interface_version() { assert!(iface_v2.user_tunnel_endpoint); } +/// Hand-craft a `Vec` whose first element is a legacy V3 +/// byte stream (discriminant 3 + V2 body + flex_algo_node_segments vec) and a +/// trailing V2 element. Asserts that the V3 bytes are fully consumed (segments +/// included) so the V2 element after them deserializes correctly — guards +/// against the byte-misalignment that broke `sdk-compat-test` on mainnet-beta +/// device `la2r-dz01` (discriminant 3 entry `Ethernet20/1`). +#[test] +fn test_interface_deprecated_consumes_legacy_v3_bytes() { + use crate::state::topology::FlexAlgoNodeSegment; + use solana_program::pubkey::Pubkey; + + // Build a V2 body and append the V3-only flex_algo_node_segments vec by hand. + let v2 = InterfaceV2 { + status: InterfaceStatus::Activated, + name: "Ethernet20/1".to_string(), + interface_type: InterfaceType::Physical, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + bandwidth: 1_000_000_000, + cir: 0, + mtu: 9000, + routing_mode: RoutingMode::BGP, + vlan_id: 0, + ip_net: "10.0.0.1/30".parse().unwrap(), + node_segment_idx: 0, + user_tunnel_endpoint: false, + }; + let segments = vec![FlexAlgoNodeSegment { + topology: Pubkey::new_unique(), + node_segment_idx: 7, + }]; + let trailing = InterfaceV2 { + name: "Ethernet1".to_string(), + ..InterfaceV2::default() + }; + + // Wire bytes: u32 vec_len=2, then [disc=3, V2 body, segments vec] + [disc=1, V2 body]. + let mut bytes = Vec::new(); + bytes.extend_from_slice(&2u32.to_le_bytes()); + bytes.push(3); // legacy V3 discriminant + bytes.extend_from_slice(&borsh::to_vec(&v2).unwrap()); + bytes.extend_from_slice(&borsh::to_vec(&segments).unwrap()); + bytes.push(1); // V2 discriminant + bytes.extend_from_slice(&borsh::to_vec(&trailing).unwrap()); + + let decoded: Vec = borsh::BorshDeserialize::try_from_slice(&bytes) + .expect("legacy V3 bytes should be consumed without misalignment"); + assert_eq!(decoded.len(), 2); + match &decoded[0] { + InterfaceDeprecated::V2(d) => { + assert_eq!(d.name, "Ethernet20/1"); + assert_eq!(d.mtu, 9000); + } + other => panic!("expected V3 to project to V2, got {:?}", other), + } + match &decoded[1] { + InterfaceDeprecated::V2(d) => assert_eq!(d.name, "Ethernet1"), + other => panic!("expected trailing V2, got {:?}", other), + } +} + #[cfg(test)] mod test_interface_validate { use super::*; diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index f1f5b7dab5..ddc0653d59 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -90,6 +90,10 @@ func DeserializeContributor(reader *ByteReader, contributor *Contributor) { // 0 — V1: original format (no CYOA/DIA/Bandwidth fields) // 1 — V2: adds CYOA, DIA, Bandwidth, Cir, Mtu, RoutingMode // 2 — reserved, never written +// 3 — V3 (legacy): V2 body + a flex_algo_node_segments vec. No longer +// written, but pre-existing on-chain accounts still contain V3 entries +// in the legacy slot. We consume the bytes and surface as V2 — segments +// live in the trailing forward-compat interfaces vec on Device. // // The on-chain Device serializer projects the legacy deprecated_interfaces slot as V2 // (per #3653). flex_algo_node_segments lives only in the trailing forward-compat @@ -102,6 +106,13 @@ func DeserializeInterface(reader *ByteReader, iface *Interface) { DeserializeInterfaceV1(reader, iface) case 1, 2: // V2 DeserializeInterfaceV2(reader, iface) + case 3: // legacy V3 — consume V2 body + drop segments + DeserializeInterfaceV2(reader, iface) + segCount := reader.ReadU32() + for i := uint32(0); i < segCount; i++ { + _ = reader.ReadPubkey() + _ = reader.ReadU16() + } default: log.Println("DeserializeInterface: Unsupported interface version", iface.Version) } From 594699f59d0afe6f351e24d32f2230dc23fa443b Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Wed, 13 May 2026 05:22:06 +0000 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 919dbbcac6..6ecfe06de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,9 @@ All notable changes to this project will be documented in this file. ### Changes - Smartcontract - - Delete `InterfaceV3` and the `InterfaceDeprecated::V3` variant from the serviceability program. The V3 type, its helper impls (`From`, `TryFrom<&InterfaceV1>`, `Default`, `TryFrom<&InterfaceV3> for InterfaceV2`), the V3 match arms in `InterfaceDeprecated::to_v2`/`size`/`Device::TryFrom`, and the V3 cross-language byte-layout debug test are removed. The custom `BorshDeserialize for InterfaceDeprecated` keeps a discriminant-3 read shim that consumes the V2 body + the trailing `flex_algo_node_segments` vec and surfaces the entry as `InterfaceDeprecated::V2` — required because mainnet-beta still has one device (`la2r-dz01`, interface `Ethernet20/1`) with a V3 entry in its legacy slot. Segments are dropped on this read path; they live in the trailing forward-compat `Device::interfaces` vec post-#3667. On-disk write format is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) + - Delete `InterfaceV3` and the `InterfaceDeprecated::V3` variant from the serviceability program. V3 was added by an earlier change, never written to production accounts, and reverted in #3653 / no longer produced after #3667; this removes the dead type. Discriminant 3 is now an unused reserved slot in `InterfaceDeprecated`'s encoding space — unknown discriminants fall through to `InterfaceV2::default()`. Removes the V3 struct, its helper impls (`From`, `TryFrom<&InterfaceV1>`, `Default`, `TryFrom<&InterfaceV3> for InterfaceV2`), V3 match arms in `InterfaceDeprecated::to_v2`/`size`/`Device::TryFrom`, and the V3 cross-language byte-layout debug test. On-disk write format is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) - SDK - - Same legacy-V3 read shim in the Go, Python, and TypeScript serviceability readers: discriminant-3 entries in the legacy `deprecated_interfaces` slot are read as V2 (V2 body + consumed-and-dropped segments vec). The forward-compat trailing `interfaces` vec continues to carry `flex_algo_node_segments` via the size-prefixed body — that path is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) + - Drop V3 handling from the Go, Python, and TypeScript serviceability readers: remove `DeserializeInterfaceV3` (Go) and the `version === 3` / `version == 3` legacy-slot branches (Python/TS); remove the `TestDeserializeInterfaceV3CrossLanguage` Go test. The forward-compat trailing `interfaces` vec continues to carry `flex_algo_node_segments` via the size-prefixed body — that path is unchanged ([#3664](https://github.com/malbeclabs/doublezero/issues/3664)) ## [v0.22.0](https://github.com/malbeclabs/doublezero/compare/client/v0.21.0...client/v0.22.0) - 2026-05-08