Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

### Changes

- Smartcontract
- Canonicalize the forward-compatible Device interface names: rename the new struct from `NewInterface` to `Interface`, the legacy enum from `Interface` to `InterfaceDeprecated`, the `Device::new_interfaces` field to `Device::interfaces`, and the legacy `Device::interfaces` field to `Device::deprecated_interfaces`. On-disk format and behavior are unchanged — Borsh is positional, so the regenerated device fixture is byte-identical. Mechanical rename across the serviceability program, processors, CLI, sentinel, client, controlplane admin, and the Rust SDK. The activator/ crate was already deleted in an earlier change so the rename does not touch it ([#3663](https://github.com/malbeclabs/doublezero/issues/3663))
- Stop reading `Device::deprecated_interfaces` outside of backward-compat tests. The `delete_device` processor and `Device::validate` now check the canonical `Device::interfaces` vec; the legacy slot is treated as wire-format-only state populated by the deserializer and projected by the serializer. CLI, processor, and Rust SDK construction sites use `..Default::default()` to avoid initializing the legacy field. The unused `UpdateDeviceCommand::deprecated_interfaces` SDK command field is removed. `Device::deprecated_interfaces` itself is retained for backward-compat fixture tests that verify legacy-slot decoding ([#3663](https://github.com/malbeclabs/doublezero/issues/3663))
- Remove the `CurrentInterfaceVersion` type alias and the unused `Device::find_interface_legacy` helper. Tests that used to construct `CurrentInterfaceVersion {...}` (an `InterfaceV2` literal) and convert into either the canonical `Interface` or the legacy `InterfaceDeprecated` enum now build `Interface {...}` directly. The legacy enum's `into_current_version()` method is replaced by `to_v2()` for the few backward-compat sites that still need an `InterfaceV2` projection ([#3663](https://github.com/malbeclabs/doublezero/issues/3663))
- SDK
- Apply the same rename in the Go, Python, and TypeScript serviceability readers: Go gets `Device.DeprecatedInterfaces` ↔ `Device.Interfaces` (with the new `Interfaces` taking the trailing-vec slot previously held by `NewInterfaces`); Python gets `Device.deprecated_interfaces` ↔ `Device.interfaces`; TypeScript gets `Device.deprecatedInterfaces` ↔ `Device.interfaces`. The `Interface` / `DeviceInterface` element type in each SDK already represents the canonical (new) format and needs no rename. Length-mismatch error messages now reference the canonical field names ([#3663](https://github.com/malbeclabs/doublezero/issues/3663))
- Smartcontract
- Migrate read callers in the CLI, sentinel, client, controlplane admin, and Rust SDK topology helper to read interfaces from `Device::new_interfaces` instead of the legacy `interfaces` enum vec, and adopt the `Device::find_interface` signature that returns `&NewInterface`. The legacy `interfaces` slot is still written on-disk via the per-write V2 projection from #3667; this PR only migrates reads. The temporary `Device::find_interface_legacy` helper is retained for the smartcontract program processors, which migrate in a later issue. Activator is intentionally excluded — it is deprecated ([#3659](https://github.com/malbeclabs/doublezero/issues/3659))
- Activator
Expand All @@ -21,6 +27,10 @@ All notable changes to this project will be documented in this file.
- SDK
- Go, Python, and TypeScript serviceability readers parse the trailing `new_interfaces` vec on `Device` with size-prefixed (u16 size + u8 version + body) forward-compat framing. Empty trailing falls back to rebuilding `new_interfaces` from the legacy enum vec, matching the Rust device reader. Length mismatch between the legacy and trailing vecs is surfaced as an error (Python/TS raise; Go sets `Device.DeserializeError`). Bumps `CURRENT_INTERFACE_VERSION` / `CurrentInterfaceVersion` to `4` across SDKs to match Rust's `CURRENT_INTERFACE_SCHEMA_VERSION` ([#3660](https://github.com/malbeclabs/doublezero/issues/3660))
- Regenerate `device.{bin,json}` through Device's custom serializer with a populated `new_interfaces` vec (one Vpnv4 loopback carrying a `FlexAlgoNodeSegment`, one physical user-tunnel-endpoint), and add `device_legacy.{bin,json}` (legacy `interfaces` vec only, no trailing bytes — exercises the SDK legacy-fallback path) and `device_future_version.{bin,json}` (last trailing-vec element doctored to `version=5` with 8 trailing junk bytes — exercises the SDK skip-to-end path). Adds fixture-driven Go SDK tests; extends the existing Python/TS fixture tests to cover all three Device fixtures ([#3661](https://github.com/malbeclabs/doublezero/issues/3661))
- Smartcontract
- Delete `InterfaceV3` and the `InterfaceDeprecated::V3` variant from the serviceability program. The V3 type, its helper impls (`From<InterfaceV2>`, `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
- 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.21.0](https://github.com/malbeclabs/doublezero/compare/client/v0.20.0...client/v0.21.0) - 2026-05-01

Expand Down
2 changes: 1 addition & 1 deletion client/doublezero/src/command/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1337,7 +1337,6 @@ mod tests {
dz_prefixes: format!("10.{}.0.0/24", device_number).parse().unwrap(),
mgmt_vrf: "default".to_string(),
interfaces: vec![],
new_interfaces: vec![],
max_users: 255,
users_count: 0,
device_health:
Expand All @@ -1351,6 +1350,7 @@ mod tests {
reserved_seats: 0,
multicast_publishers_count: 0,
max_multicast_publishers: 0,
..Default::default()
};
devices.insert(pk, device.clone());
(pk, device)
Expand Down
16 changes: 7 additions & 9 deletions client/doublezero/src/dzd_latency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pub(crate) fn get_device_tunnel_endpoints(device: &Device) -> Vec<Ipv4Addr> {
let mut endpoints = vec![device.public_ip];

// Add all UserTunnelEndpoint interfaces
for iface in &device.new_interfaces {
for iface in &device.interfaces {
if iface.user_tunnel_endpoint && iface.ip_net != Default::default() {
endpoints.push(iface.ip_net.ip());
}
Expand Down Expand Up @@ -236,8 +236,8 @@ mod tests {
use crate::servicecontroller::{LatencyRecord, LatencyResponse, MockServiceController};
use doublezero_program_common::types::{NetworkV4, NetworkV4List};
use doublezero_sdk::{
AccountType, CurrentInterfaceVersion, Device, DeviceStatus, DeviceType, Interface,
InterfaceStatus, InterfaceType, LoopbackType,
AccountType, Device, DeviceStatus, DeviceType, Interface, InterfaceStatus, InterfaceType,
LoopbackType,
};
use doublezero_serviceability::state::interface::{InterfaceCYOA, InterfaceDIA, RoutingMode};
use solana_sdk::pubkey::Pubkey;
Expand All @@ -254,10 +254,10 @@ mod tests {
tunnel_endpoint_ips: Vec<Ipv4Addr>,
) -> (Pubkey, Device) {
let pubkey = Pubkey::new_unique();
let v2_ifaces: Vec<CurrentInterfaceVersion> = tunnel_endpoint_ips
let interfaces: Vec<Interface> = tunnel_endpoint_ips
.into_iter()
.enumerate()
.map(|(i, ip)| CurrentInterfaceVersion {
.map(|(i, ip)| Interface {
status: InterfaceStatus::Activated,
name: format!("Loopback{}", i),
interface_type: InterfaceType::Loopback,
Expand All @@ -272,11 +272,9 @@ mod tests {
ip_net: NetworkV4::new(ip, 32).unwrap(),
node_segment_idx: 0,
user_tunnel_endpoint: true,
..Default::default()
})
.collect();
let interfaces: Vec<Interface> =
v2_ifaces.iter().map(|v| Interface::V2(v.clone())).collect();
let new_interfaces = v2_ifaces.iter().map(|v| v.try_into().unwrap()).collect();
(
pubkey,
Device {
Expand All @@ -295,7 +293,6 @@ mod tests {
contributor_pk: Pubkey::default(),
mgmt_vrf: "default".to_string(),
interfaces,
new_interfaces,
reference_count: 0,
users_count,
max_users: 1,
Expand All @@ -310,6 +307,7 @@ mod tests {
reserved_seats: 0,
multicast_publishers_count: 0,
max_multicast_publishers: 0,
..Default::default()
},
)
}
Expand Down
4 changes: 1 addition & 3 deletions controlplane/doublezero-admin/src/cli/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ impl MigrateMulticastCountsCommand {
contributor_pk: None,
location_pk: None,
mgmt_vrf: None,
interfaces: None,
max_users: None,
users_count: None,
status: None,
Expand Down Expand Up @@ -257,7 +256,6 @@ impl MigrateUnicastCountsCommand {
contributor_pk: None,
location_pk: None,
mgmt_vrf: None,
interfaces: None,
max_users: None,
users_count: None,
status: None,
Expand Down Expand Up @@ -312,7 +310,6 @@ mod tests {
dz_prefixes: "10.0.0.1/32".parse().unwrap(),
mgmt_vrf: "default".to_string(),
interfaces: vec![],
new_interfaces: vec![],
max_users: 255,
users_count: 0,
device_health: DeviceHealth::ReadyForUsers,
Expand All @@ -324,6 +321,7 @@ mod tests {
reserved_seats: 0,
multicast_publishers_count: pub_count,
max_multicast_publishers: 0,
..Default::default()
}
}

Expand Down
2 changes: 1 addition & 1 deletion controlplane/doublezero-admin/src/cli/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ impl FlexAlgoMigrateCliCommand {
let mut devices_needing_backfill: Vec<Pubkey> = Vec::new();

for (device_pubkey, device) in &device_entries {
let needs_backfill = device.new_interfaces.iter().any(|iface| {
let needs_backfill = device.interfaces.iter().any(|iface| {
iface.loopback_type == LoopbackType::Vpnv4
&& !iface
.flex_algo_node_segments
Expand Down
2 changes: 1 addition & 1 deletion crates/sentinel/src/dz_ledger_reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ pub fn fetch_device_infos(
.copied()
.unwrap_or((0.0, 0.0));
let user_tunnel_endpoints = device
.new_interfaces
.interfaces
.iter()
.filter_map(|iface| {
if iface.user_tunnel_endpoint && iface.ip_net != Default::default() {
Expand Down
2 changes: 1 addition & 1 deletion crates/sentinel/src/multicast_publisher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ impl MulticastDzLedgerClient for RpcMulticastDzLedgerClient {
continue;
}
let user_tunnel_endpoints = device
.new_interfaces
.interfaces
.iter()
.filter_map(|iface| {
if iface.user_tunnel_endpoint && iface.ip_net != Default::default() {
Expand Down
66 changes: 33 additions & 33 deletions sdk/serviceability/python/serviceability/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,12 +401,12 @@ def __str__(self) -> str:
# Account dataclasses
# ---------------------------------------------------------------------------

# On-wire schema version for the size-prefixed NewInterface format
# 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


Expand Down Expand Up @@ -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 (no flex_algo_node_segments),
# 3=V3 (V2 fields + flex_algo_node_segments).
# 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()
Expand All @@ -472,17 +474,15 @@ def from_reader(cls, r: DefensiveReader) -> Interface:
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)
seg_count = r.read_u32()
for _ in range(seg_count):
_read_pubkey(r)
r.read_u16()
return iface

@classmethod
def from_reader_sized(cls, r: DefensiveReader) -> Interface:
"""Read a single size-prefixed NewInterface element.
"""Read a single size-prefixed Interface element.

Wire format: u16 size (incl. 3-byte prefix) + u8 version + body. After
reading the known body fields, the reader is advanced to start+size so
Expand All @@ -493,8 +493,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())
Expand Down Expand Up @@ -681,7 +681,7 @@ class Device:
metrics_publisher_pub_key: Pubkey = Pubkey.default()
contributor_pub_key: Pubkey = Pubkey.default()
mgmt_vrf: str = ""
interfaces: list[Interface] = field(default_factory=list)
deprecated_interfaces: list[Interface] = field(default_factory=list)
reference_count: int = 0
users_count: int = 0
max_users: int = 0
Expand All @@ -694,11 +694,11 @@ class Device:
reserved_seats: int = 0
multicast_publishers_count: int = 0
max_multicast_publishers: int = 0
# new_interfaces is the trailing size-prefixed vec parallel to interfaces. For
# legacy accounts (no trailing bytes), this is rebuilt from interfaces by
# from_bytes. When populated from the wire, len(new_interfaces) ==
# len(interfaces) is enforced.
new_interfaces: list[Interface] = field(default_factory=list)
# interfaces is the trailing size-prefixed vec parallel to deprecated_interfaces.
# For legacy accounts (no trailing bytes), this is rebuilt from deprecated_interfaces
# by from_bytes. When populated from the wire, len(interfaces) ==
# len(deprecated_interfaces) is enforced.
interfaces: list[Interface] = field(default_factory=list)

@classmethod
def from_bytes(cls, data: bytes) -> Device:
Expand All @@ -719,7 +719,7 @@ def from_bytes(cls, data: bytes) -> Device:
dev.contributor_pub_key = _read_pubkey(r)
dev.mgmt_vrf = r.read_string()
iface_len = r.read_u32()
dev.interfaces = [Interface.from_reader(r) for _ in range(iface_len)]
dev.deprecated_interfaces = [Interface.from_reader(r) for _ in range(iface_len)]
dev.reference_count = r.read_u32()
dev.users_count = r.read_u16()
dev.max_users = r.read_u16()
Expand All @@ -733,13 +733,13 @@ def from_bytes(cls, data: bytes) -> Device:
dev.multicast_publishers_count = r.read_u16()
dev.max_multicast_publishers = r.read_u16()

# Trailing new_interfaces vec (size-prefixed). Empty trailing => rebuild
# from legacy. Non-empty trailing whose declared length differs from the
# legacy interfaces length is a corrupt-account condition; raise per
# Rust device-reader semantics (length mismatch is fatal).
# Trailing interfaces vec (size-prefixed). Empty trailing => rebuild
# from deprecated_interfaces. Non-empty trailing whose declared length
# differs from the deprecated_interfaces length is a corrupt-account
# condition; raise per Rust device-reader semantics (length mismatch is fatal).
if r.remaining == 0:
dev.new_interfaces = []
for legacy in dev.interfaces:
dev.interfaces = []
for legacy in dev.deprecated_interfaces:
rebuilt = Interface(
size=0,
version=CURRENT_INTERFACE_VERSION,
Expand All @@ -759,15 +759,15 @@ def from_bytes(cls, data: bytes) -> Device:
user_tunnel_endpoint=legacy.user_tunnel_endpoint,
flex_algo_node_segments=list(legacy.flex_algo_node_segments),
)
dev.new_interfaces.append(rebuilt)
dev.interfaces.append(rebuilt)
else:
new_len = r.read_u32()
if new_len != len(dev.interfaces):
if new_len != len(dev.deprecated_interfaces):
raise ValueError(
f"Device new_interfaces length {new_len} != "
f"interfaces length {len(dev.interfaces)}"
f"Device interfaces length {new_len} != "
f"deprecated_interfaces length {len(dev.deprecated_interfaces)}"
)
dev.new_interfaces = [
dev.interfaces = [
Interface.from_reader_sized(r) for _ in range(new_len)
]

Expand Down
Loading
Loading