Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ All notable changes to this project will be documented in this file.
- Append `new_interfaces: Vec<NewInterface>` to `Device` after `max_multicast_publishers`, behind a custom `BorshSerialize` that projects the on-disk legacy `interfaces` slot from `new_interfaces` (always `Interface::V2` per #3653) and writes `new_interfaces` at the end of the layout. Legacy accounts with no trailing bytes deserialize cleanly: `Device::try_from` rebuilds `new_interfaces` from the legacy enum vec via per-variant `TryFrom`. Older readers continue to parse the legacy slot at its existing offset; newer readers gain forward-compat via the trailing vec. Mutations now go through `Device::replace_interface` / `push_interface` / `remove_interface` so both vecs stay in sync; `find_interface` returns `&NewInterface` and `find_interface_legacy` is a temporary helper for unrelated callers ([#3665](https://github.com/malbeclabs/doublezero/pull/3665))
- Migrate serviceability processors (`device/interface/{create,update,activate,delete,reject,remove,unlink}`, `link/{accept,activate,closeaccount,create,delete,update}`, `topology/backfill`) to read and mutate `Device::new_interfaces` directly. `device.interfaces` is no longer touched in `processors/`, and `Device::push_interface` now takes a `NewInterface`. `BackfillTopology` no longer mirrors `flex_algo_node_segments` into the legacy in-memory `interfaces` vec — segments live only in `new_interfaces` and are intentionally dropped on the V2-projected on-disk legacy slot ([#3658](https://github.com/malbeclabs/doublezero/issues/3658))
- Delete the `MigrateDeviceInterfaces` processor and integration tests from the serviceability program. `Device::TryFrom<&[u8]>` (#3665) now auto-promotes legacy `interfaces` into `new_interfaces` when the trailing vec is missing, and the next account write persists the promoted vec — no standalone migration step is needed. Variant 111 is retained as a `Deprecated111()` tombstone (no-op dispatch, slot reserved so it isn't reused) for compatibility with older clients still emitting the old discriminator ([#3662](https://github.com/malbeclabs/doublezero/issues/3662))
- 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))

## [v0.21.0](https://github.com/malbeclabs/doublezero/compare/client/v0.20.0...client/v0.21.0) - 2026-05-01

Expand Down
122 changes: 113 additions & 9 deletions sdk/serviceability/python/serviceability/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,13 @@ def __str__(self) -> str:
# Account dataclasses
# ---------------------------------------------------------------------------

CURRENT_INTERFACE_VERSION = 2
# On-wire schema version for the size-prefixed NewInterface 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).
CURRENT_INTERFACE_VERSION = 4


@dataclass
Expand All @@ -412,6 +418,10 @@ class FlexAlgoNodeSegment:

@dataclass
class Interface:
# size is the on-disk byte length of the size-prefixed encoding (u16 size +
# u8 version + body). Populated only when read via from_reader_sized; zero
# for legacy enum reads.
size: int = 0
version: int = 0
status: InterfaceStatus = InterfaceStatus.INVALID
name: str = ""
Expand All @@ -435,7 +445,9 @@ def from_reader(cls, r: DefensiveReader) -> Interface:
iface.version = r.read_u8()
if iface.version > CURRENT_INTERFACE_VERSION - 1:
return iface
if iface.version == 0: # V1
# Discriminants: 0=V1, 1 or 2=V2 (no flex_algo_node_segments),
# 3=V3 (V2 fields + flex_algo_node_segments).
if iface.version == 0:
iface.status = InterfaceStatus(r.read_u8())
iface.name = r.read_string()
iface.interface_type = InterfaceType(r.read_u8())
Expand All @@ -444,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 == 1: # V2
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())
Expand All @@ -459,12 +471,60 @@ 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()
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)
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
def from_reader_sized(cls, r: DefensiveReader) -> Interface:
"""Read a single size-prefixed NewInterface 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
unknown future versions are skipped in O(1).
"""
iface = cls()
start = r.offset
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.
iface.status = InterfaceStatus(r.read_u8())
iface.name = r.read_string()
iface.interface_type = InterfaceType(r.read_u8())
iface.interface_cyoa = InterfaceCYOA(r.read_u8())
iface.interface_dia = InterfaceDIA(r.read_u8())
iface.loopback_type = LoopbackType(r.read_u8())
iface.bandwidth = r.read_u64()
iface.cir = r.read_u64()
iface.mtu = r.read_u16()
iface.routing_mode = RoutingMode(r.read_u8())
iface.vlan_id = r.read_u16()
iface.ip_net = r.read_network_v4()
iface.node_segment_idx = r.read_u16()
iface.user_tunnel_endpoint = r.read_bool()
seg_count = r.read_u32()
for _ in range(seg_count):
# Defensive guard against garbage seg_count when the body is shorter
# than expected (e.g. older size-prefixed encoding).
if r.remaining < 34: # 32 (pubkey) + 2 (u16)
break
seg = FlexAlgoNodeSegment()
seg.topology = _read_pubkey(r)
seg.node_segment_idx = r.read_u16()
iface.flex_algo_node_segments.append(seg)

Comment thread
elitegreg marked this conversation as resolved.
# Advance the reader to start+size regardless of how many body bytes
# we consumed.
target = start + iface.size
if r.offset < target:
r.read_bytes(target - r.offset)
return iface


Expand Down Expand Up @@ -634,6 +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)

@classmethod
def from_bytes(cls, data: bytes) -> Device:
Expand Down Expand Up @@ -667,6 +732,45 @@ def from_bytes(cls, data: bytes) -> Device:
dev.reserved_seats = r.read_u16()
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).
if r.remaining == 0:
dev.new_interfaces = []
for legacy in dev.interfaces:
rebuilt = Interface(
size=0,
version=CURRENT_INTERFACE_VERSION,
status=legacy.status,
name=legacy.name,
interface_type=legacy.interface_type,
interface_cyoa=legacy.interface_cyoa,
interface_dia=legacy.interface_dia,
loopback_type=legacy.loopback_type,
bandwidth=legacy.bandwidth,
cir=legacy.cir,
mtu=legacy.mtu,
routing_mode=legacy.routing_mode,
vlan_id=legacy.vlan_id,
ip_net=legacy.ip_net,
node_segment_idx=legacy.node_segment_idx,
user_tunnel_endpoint=legacy.user_tunnel_endpoint,
flex_algo_node_segments=list(legacy.flex_algo_node_segments),
)
dev.new_interfaces.append(rebuilt)
else:
new_len = r.read_u32()
if new_len != len(dev.interfaces):
raise ValueError(
f"Device new_interfaces length {new_len} != "
f"interfaces length {len(dev.interfaces)}"
)
dev.new_interfaces = [
Interface.from_reader_sized(r) for _ in range(new_len)
]

return dev


Expand Down
180 changes: 180 additions & 0 deletions sdk/serviceability/python/serviceability/tests/test_new_interface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Hand-built byte-vector tests for the size-prefixed NewInterface reader.

The wire format mirrors smartcontract/programs/doublezero-serviceability::state::device:
each NewInterface element is (u16 size, u8 version, body), where size includes
the 3-byte prefix. The Device account stores this vec immediately after
max_multicast_publishers.
"""

from __future__ import annotations

import struct

import pytest

from serviceability.state import (
CURRENT_INTERFACE_VERSION,
AccountTypeEnum,
Device,
)


def _u16(v: int) -> bytes:
return struct.pack("<H", v)


def _u32(v: int) -> bytes:
return struct.pack("<I", v)


def _u64(v: int) -> bytes:
return struct.pack("<Q", v)


def _string(s: str) -> bytes:
return _u32(len(s)) + s.encode("utf-8")


def _new_interface_body(name: str) -> bytes:
"""Body bytes for a minimal V4 NewInterface with caller-provided name."""
parts: list[bytes] = []
parts.append(b"\x00") # status
parts.append(_string(name))
parts.append(b"\x00") # interface_type
parts.append(b"\x00") # interface_cyoa
parts.append(b"\x00") # interface_dia
parts.append(b"\x00") # loopback_type
parts.append(_u64(0)) # bandwidth
parts.append(_u64(0)) # cir
parts.append(_u16(0)) # mtu
parts.append(b"\x00") # routing_mode
parts.append(_u16(0)) # vlan_id
parts.append(b"\x00" * 5) # ip_net (NetworkV4: 4 bytes IP + 1 byte prefix)
parts.append(_u16(0)) # node_segment_idx
parts.append(b"\x00") # user_tunnel_endpoint
parts.append(_u32(0)) # flex_algo_node_segments len = 0
return b"".join(parts)


def _new_interface_sized(name: str, version: int = CURRENT_INTERFACE_VERSION,
body_override: bytes | None = None) -> bytes:
body = body_override if body_override is not None else _new_interface_body(name)
size = 3 + len(body)
return _u16(size) + bytes([version]) + body


def _legacy_interface_v1(name: str) -> bytes:
"""A single V1-disc legacy enum interface with caller-provided name.

V1 is used over V2 here because Python's V2 reader also consumes a trailing
flex_algo_node_segments u32 that the Go V2 reader does not — V1 sidesteps
that asymmetry without affecting what we're testing (the trailing vec).
"""
return (
bytes([0]) # enum disc V1
+ b"\x00" # status
+ _string(name)
+ b"\x00" # interface_type
+ b"\x00" # loopback_type
+ _u16(0) # vlan_id
+ b"\x00" * 5 # ip_net
+ _u16(0) # node_segment_idx
+ b"\x00" # user_tunnel_endpoint
)


def _device(num_legacy: int, names: list[str], trailing: bytes | None) -> bytes:
parts: list[bytes] = []
parts.append(bytes([int(AccountTypeEnum.DEVICE)])) # account_type
parts.append(b"\x00" * 32) # owner
parts.append(_u64(0) + _u64(1)) # index (u128 little-endian as two u64s)
parts.append(b"\xff") # bump_seed
parts.append(b"\x00" * 32) # location_pk
parts.append(b"\x00" * 32) # exchange_pk
parts.append(b"\x00") # device_type
parts.append(b"\x01\x02\x03\x04") # public_ip
parts.append(b"\x01") # status (Activated)
parts.append(_string("dev-test")) # code
parts.append(_u32(0)) # dz_prefixes (empty)
parts.append(b"\x00" * 32) # metrics_publisher_pk
parts.append(b"\x00" * 32) # contributor_pk
parts.append(_string("default")) # mgmt_vrf
parts.append(_u32(num_legacy))
for name in names:
parts.append(_legacy_interface_v1(name))
parts.append(_u32(0)) # reference_count
parts.append(_u16(0)) # users_count
parts.append(_u16(0)) # max_users
parts.append(b"\x00") # device_health
parts.append(b"\x00") # device_desired_status
parts.append(_u16(0)) # unicast_users_count
parts.append(_u16(0)) # multicast_subscribers_count
parts.append(_u16(0)) # max_unicast_users
parts.append(_u16(0)) # max_multicast_subscribers
parts.append(_u16(0)) # reserved_seats
parts.append(_u16(0)) # multicast_publishers_count
parts.append(_u16(0)) # max_multicast_publishers
if trailing is not None:
parts.append(trailing)
return b"".join(parts)


def test_populated_trailing_vec():
# Cross-language framing assertion: empty-name body length is
# 1+4+1+1+1+1+8+8+2+1+2+5+2+1+4 = 42, so size = 3 + 42 = 45.
assert 3 + len(_new_interface_body("")) == 45

trailing = _u32(2) + _new_interface_sized("Eth1") + _new_interface_sized("Lo0")
raw = _device(2, ["Eth1", "Lo0"], trailing)

dev = Device.from_bytes(raw)
assert len(dev.interfaces) == 2
assert len(dev.new_interfaces) == 2
assert dev.new_interfaces[0].name == "Eth1"
assert dev.new_interfaces[1].name == "Lo0"
assert dev.new_interfaces[0].version == CURRENT_INTERFACE_VERSION
for i, ni in enumerate(dev.new_interfaces):
expected_size = 3 + len(_new_interface_body(ni.name))
assert ni.size == expected_size, (
f"size mismatch on element {i}: expected {expected_size}, got {ni.size}"
)


def test_legacy_account_rebuilds_new_interfaces():
raw = _device(2, ["Eth1", "Lo0"], trailing=None)

dev = Device.from_bytes(raw)
assert len(dev.interfaces) == 2
assert len(dev.new_interfaces) == 2
assert dev.new_interfaces[0].name == "Eth1"
assert dev.new_interfaces[1].name == "Lo0"
# Rebuilt entries are stamped with the current schema version and zero size
# (callers don't need on-disk size for a rebuild).
for ni in dev.new_interfaces:
assert ni.version == CURRENT_INTERFACE_VERSION
assert ni.size == 0


def test_trailing_length_mismatch_raises():
trailing = _u32(1) + _new_interface_sized("Eth1") # only 1, but legacy has 2
raw = _device(2, ["Eth1", "Lo0"], trailing)

with pytest.raises(ValueError, match="length 1 != interfaces length 2"):
Device.from_bytes(raw)


def test_future_version_skips_trailing_bytes():
# Forge an element with version=5 and 8 trailing junk bytes appended past
# the known body. The reader must advance past start+size and leave the
# next element readable.
body = _new_interface_body("Future1") + bytes([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE])
sized = _new_interface_sized("Future1", version=5, body_override=body)
trailing = _u32(1) + sized
raw = _device(1, ["Future1"], trailing)

dev = Device.from_bytes(raw)
assert len(dev.new_interfaces) == 1
assert dev.new_interfaces[0].version == 5
assert dev.new_interfaces[0].size == 3 + len(body)
# Body fields up to known shape are still parsed.
assert dev.new_interfaces[0].name == "Future1"
Binary file modified sdk/serviceability/testdata/fixtures/device.bin
Binary file not shown.
Loading
Loading