diff --git a/CHANGELOG.md b/CHANGELOG.md index e296657b16..ffdbff036a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to this project will be documented in this file. - Append `new_interfaces: Vec` 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 diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index c91edf8895..f1067619b1 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -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 @@ -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 = "" @@ -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()) @@ -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()) @@ -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) + + # 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 @@ -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: @@ -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 diff --git a/sdk/serviceability/python/serviceability/tests/test_new_interface.py b/sdk/serviceability/python/serviceability/tests/test_new_interface.py new file mode 100644 index 0000000000..b715d8038f --- /dev/null +++ b/sdk/serviceability/python/serviceability/tests/test_new_interface.py @@ -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(" bytes: + return struct.pack(" bytes: + return struct.pack(" 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" diff --git a/sdk/serviceability/testdata/fixtures/device.bin b/sdk/serviceability/testdata/fixtures/device.bin index 5e322d10ae..8554f47fb1 100644 Binary files a/sdk/serviceability/testdata/fixtures/device.bin and b/sdk/serviceability/testdata/fixtures/device.bin differ diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 08bb06aacf..48c1ecc657 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -13,6 +13,8 @@ use std::fs; use std::net::Ipv4Addr; use std::path::Path; +use borsh::BorshSerialize; + use doublezero_serviceability::id_allocator::IdAllocator; use doublezero_serviceability::ip_allocator::IpAllocator; use doublezero_serviceability::programversion::ProgramVersion; @@ -325,11 +327,6 @@ fn generate_device(dir: &Path) { user_tunnel_endpoint: true, }), ], - // Empty for now: regenerating with my custom Device serializer would - // V2-project the legacy slot from new_interfaces, dropping the V1 form - // and breaking SDK fixtures that pin Interface0 to V1. Existing - // device.bin remains in the legacy format and continues to pass SDK - // tests via the legacy fallback path in `Device::TryFrom`. new_interfaces: vec![], reference_count: 12, users_count: 5, @@ -345,7 +342,39 @@ fn generate_device(dir: &Path) { max_multicast_publishers: 10, }; - let data = borsh::to_vec(&val).unwrap(); + // Serialize each field manually to bypass `Device`'s custom `BorshSerialize`, + // which projects the legacy slot from `new_interfaces` (always V2) and + // appends a `new_interfaces` trailing vec. The fixture pins Interface0 to V1 + // and Interface1 to V2 in the pre-#3667 on-disk format, which the SDK + // exercises via the legacy fallback path in `Device::TryFrom`. + let mut data = Vec::new(); + BorshSerialize::serialize(&val.account_type, &mut data).unwrap(); + BorshSerialize::serialize(&val.owner, &mut data).unwrap(); + BorshSerialize::serialize(&val.index, &mut data).unwrap(); + BorshSerialize::serialize(&val.bump_seed, &mut data).unwrap(); + BorshSerialize::serialize(&val.location_pk, &mut data).unwrap(); + BorshSerialize::serialize(&val.exchange_pk, &mut data).unwrap(); + BorshSerialize::serialize(&val.device_type, &mut data).unwrap(); + BorshSerialize::serialize(&val.public_ip, &mut data).unwrap(); + BorshSerialize::serialize(&val.status, &mut data).unwrap(); + BorshSerialize::serialize(&val.code, &mut data).unwrap(); + BorshSerialize::serialize(&val.dz_prefixes, &mut data).unwrap(); + BorshSerialize::serialize(&val.metrics_publisher_pk, &mut data).unwrap(); + BorshSerialize::serialize(&val.contributor_pk, &mut data).unwrap(); + BorshSerialize::serialize(&val.mgmt_vrf, &mut data).unwrap(); + BorshSerialize::serialize(&val.interfaces, &mut data).unwrap(); + BorshSerialize::serialize(&val.reference_count, &mut data).unwrap(); + BorshSerialize::serialize(&val.users_count, &mut data).unwrap(); + BorshSerialize::serialize(&val.max_users, &mut data).unwrap(); + BorshSerialize::serialize(&val.device_health, &mut data).unwrap(); + BorshSerialize::serialize(&val.desired_status, &mut data).unwrap(); + BorshSerialize::serialize(&val.unicast_users_count, &mut data).unwrap(); + BorshSerialize::serialize(&val.multicast_subscribers_count, &mut data).unwrap(); + BorshSerialize::serialize(&val.max_unicast_users, &mut data).unwrap(); + BorshSerialize::serialize(&val.max_multicast_subscribers, &mut data).unwrap(); + BorshSerialize::serialize(&val.reserved_seats, &mut data).unwrap(); + BorshSerialize::serialize(&val.multicast_publishers_count, &mut data).unwrap(); + BorshSerialize::serialize(&val.max_multicast_publishers, &mut data).unwrap(); let meta = FixtureMeta { name: "Device".into(), diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 4f3147748e..9d9bb70461 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -476,6 +476,10 @@ export interface FlexAlgoNodeSegment { } export interface DeviceInterface { + // size is the on-disk byte length of the size-prefixed encoding (u16 size + + // u8 version + body). Populated only when read via deserializeInterfaceSized; + // zero for legacy enum reads. + size: number; version: number; status: number; name: string; @@ -494,10 +498,15 @@ export interface DeviceInterface { flexAlgoNodeSegments?: FlexAlgoNodeSegment[]; } -const 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. +const CURRENT_INTERFACE_VERSION = 4; function deserializeInterface(r: DefensiveReader): DeviceInterface { const iface: DeviceInterface = { + size: 0, version: 0, status: 0, name: "", @@ -521,8 +530,9 @@ 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). if (iface.version === 0) { - // V1 iface.status = r.readU8(); iface.name = r.readString(); iface.interfaceType = r.readU8(); @@ -531,8 +541,7 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - } else if (iface.version === 1) { - // V2 + } else if (iface.version === 1 || iface.version === 2 || iface.version === 3) { iface.status = r.readU8(); iface.name = r.readString(); iface.interfaceType = r.readU8(); @@ -547,24 +556,86 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - const segCount = r.readU32(); - const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; - for (let i = 0; i < segCount; i++) { - // Break early if there isn't enough data for a full segment. On pre-RFC-18 - // mainnet accounts, segCount reads garbage bytes from the next field, so - // without this guard the loop runs hundreds of thousands of times. - if (r.remaining < 34) break; // 32 (pubkey) + 2 (u16) - flexAlgoNodeSegments.push({ - topology: readPubkey(r), - nodeSegmentIdx: r.readU16(), - }); + 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; } - iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } return iface; } +// deserializeInterfaceSized reads a single size-prefixed NewInterface element. +// +// Wire format: u16 size + u8 version + body, where size includes the 3-byte +// prefix. Forward-compat readers always advance the cursor to start+size after +// reading the known body fields, so unknown future versions are skipped in O(1). +function deserializeInterfaceSized(r: DefensiveReader): DeviceInterface { + const start = r.offset; + 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. + const status = r.readU8(); + const name = r.readString(); + const interfaceType = r.readU8(); + const interfaceCyoa = r.readU8(); + const interfaceDia = r.readU8(); + const loopbackType = r.readU8(); + const bandwidth = r.readU64(); + const cir = r.readU64(); + const mtu = r.readU16(); + const routingMode = r.readU8(); + const vlanId = r.readU16(); + const ipNet = r.readNetworkV4(); + const nodeSegmentIdx = r.readU16(); + const userTunnelEndpoint = r.readBool(); + const segCount = r.readU32(); + const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; + for (let i = 0; i < segCount; i++) { + if (r.remaining < 34) break; // 32 (pubkey) + 2 (u16) — defensive guard + flexAlgoNodeSegments.push({ + topology: readPubkey(r), + nodeSegmentIdx: r.readU16(), + }); + } + + // Advance to start+size regardless of how many body bytes we consumed. + const target = start + size; + if (r.offset < target) { + r.readBytes(target - r.offset); + } + + return { + size, + version, + status, + name, + interfaceType, + interfaceCyoa, + interfaceDia, + loopbackType, + bandwidth, + cir, + mtu, + routingMode, + vlanId, + ipNet, + nodeSegmentIdx, + userTunnelEndpoint, + flexAlgoNodeSegments, + }; +} + // --------------------------------------------------------------------------- // Device // --------------------------------------------------------------------------- @@ -597,6 +668,11 @@ export interface Device { reservedSeats: number; multicastPublishersCount: number; maxMulticastPublishers: number; + // newInterfaces is the trailing size-prefixed vec parallel to interfaces. For + // legacy accounts (no trailing bytes), this is rebuilt from interfaces by + // deserializeDevice. When populated from the wire, length parity with + // interfaces is enforced. + newInterfaces: DeviceInterface[]; } export function deserializeDevice(data: Uint8Array): Device { @@ -635,6 +711,32 @@ export function deserializeDevice(data: Uint8Array): Device { const multicastPublishersCount = r.readU16(); const maxMulticastPublishers = r.readU16(); + // 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. + let newInterfaces: DeviceInterface[]; + if (r.remaining === 0) { + newInterfaces = interfaces.map((legacy) => ({ + ...legacy, + size: 0, + version: CURRENT_INTERFACE_VERSION, + flexAlgoNodeSegments: legacy.flexAlgoNodeSegments + ? [...legacy.flexAlgoNodeSegments] + : [], + })); + } else { + const newLen = r.readU32(); + if (newLen !== interfaces.length) { + throw new Error( + `Device new_interfaces length ${newLen} != interfaces length ${interfaces.length}`, + ); + } + newInterfaces = []; + for (let i = 0; i < newLen; i++) { + newInterfaces.push(deserializeInterfaceSized(r)); + } + } + return { accountType, owner, @@ -663,6 +765,7 @@ export function deserializeDevice(data: Uint8Array): Device { reservedSeats, multicastPublishersCount, maxMulticastPublishers, + newInterfaces, }; } diff --git a/sdk/serviceability/typescript/serviceability/tests/new_interface.test.ts b/sdk/serviceability/typescript/serviceability/tests/new_interface.test.ts new file mode 100644 index 0000000000..4af3969bfd --- /dev/null +++ b/sdk/serviceability/typescript/serviceability/tests/new_interface.test.ts @@ -0,0 +1,204 @@ +/** + * Hand-built byte-vector tests for the size-prefixed NewInterface reader. + * + * Each NewInterface element on the wire is (u16 size, u8 version, body), where + * size includes the 3-byte prefix. The Device account stores this vec + * immediately after max_multicast_publishers. + */ + +import { describe, expect, test } from "bun:test"; +import { + ACCOUNT_TYPE_DEVICE, + deserializeDevice, +} from "../state.js"; + +// CURRENT_INTERFACE_VERSION isn't exported; we hard-code the same value here +// (intentional — the wire format version is a stable cross-language constant). +const CURRENT_INTERFACE_VERSION = 4; + +function u16(v: number): Uint8Array { + const b = new Uint8Array(2); + new DataView(b.buffer).setUint16(0, v, true); + return b; +} + +function u32(v: number): Uint8Array { + const b = new Uint8Array(4); + new DataView(b.buffer).setUint32(0, v, true); + return b; +} + +function u64(v: bigint): Uint8Array { + const b = new Uint8Array(8); + new DataView(b.buffer).setBigUint64(0, v, true); + return b; +} + +function string(s: string): Uint8Array { + const enc = new TextEncoder().encode(s); + return concat(u32(enc.length), enc); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +function newInterfaceBody(name: string): Uint8Array { + return concat( + new Uint8Array([0]), // status + string(name), + new Uint8Array([0]), // interface_type + new Uint8Array([0]), // interface_cyoa + new Uint8Array([0]), // interface_dia + new Uint8Array([0]), // loopback_type + u64(0n), // bandwidth + u64(0n), // cir + u16(0), // mtu + new Uint8Array([0]), // routing_mode + u16(0), // vlan_id + new Uint8Array(5), // ip_net (NetworkV4: 4 bytes IP + 1 byte prefix) + u16(0), // node_segment_idx + new Uint8Array([0]), // user_tunnel_endpoint + u32(0), // flex_algo_node_segments len = 0 + ); +} + +function newInterfaceSized( + name: string, + version: number = CURRENT_INTERFACE_VERSION, + bodyOverride?: Uint8Array, +): Uint8Array { + const body = bodyOverride ?? newInterfaceBody(name); + const size = 3 + body.length; + return concat(u16(size), new Uint8Array([version]), body); +} + +// V1-disc legacy interface: chosen over V2 because the Python V2 reader +// consumes a trailing flex_algo_node_segments u32 that this TS V2 reader does +// not — keeping the cross-language shape simple. +function legacyInterfaceV1(name: string): Uint8Array { + return concat( + new Uint8Array([0]), // enum disc V1 + new Uint8Array([0]), // status + string(name), + new Uint8Array([0]), // interface_type + new Uint8Array([0]), // loopback_type + u16(0), // vlan_id + new Uint8Array(5), // ip_net + u16(0), // node_segment_idx + new Uint8Array([0]), // user_tunnel_endpoint + ); +} + +function buildDevice( + numLegacy: number, + names: string[], + trailing: Uint8Array | null, +): Uint8Array { + const parts: Uint8Array[] = []; + parts.push(new Uint8Array([ACCOUNT_TYPE_DEVICE])); // account_type + parts.push(new Uint8Array(32)); // owner + parts.push(concat(u64(1n), u64(0n))); // index (u128 low+high LE) + parts.push(new Uint8Array([0xff])); // bump_seed + parts.push(new Uint8Array(32)); // location_pk + parts.push(new Uint8Array(32)); // exchange_pk + parts.push(new Uint8Array([0])); // device_type + parts.push(new Uint8Array([1, 2, 3, 4])); // public_ip + parts.push(new Uint8Array([1])); // status + parts.push(string("dev-test")); // code + parts.push(u32(0)); // dz_prefixes (empty) + parts.push(new Uint8Array(32)); // metrics_publisher_pk + parts.push(new Uint8Array(32)); // contributor_pk + parts.push(string("default")); // mgmt_vrf + parts.push(u32(numLegacy)); + for (const n of names) parts.push(legacyInterfaceV1(n)); + parts.push(u32(0)); // reference_count + parts.push(u16(0)); // users_count + parts.push(u16(0)); // max_users + parts.push(new Uint8Array([0])); // device_health + parts.push(new Uint8Array([0])); // device_desired_status + parts.push(u16(0)); // unicast_users_count + parts.push(u16(0)); // multicast_subscribers_count + parts.push(u16(0)); // max_unicast_users + parts.push(u16(0)); // max_multicast_subscribers + parts.push(u16(0)); // reserved_seats + parts.push(u16(0)); // multicast_publishers_count + parts.push(u16(0)); // max_multicast_publishers + if (trailing !== null) parts.push(trailing); + return concat(...parts); +} + +describe("size-prefixed NewInterface", () => { + 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. + expect(3 + newInterfaceBody("").length).toBe(45); + + const trailing = concat( + u32(2), + newInterfaceSized("Eth1"), + newInterfaceSized("Lo0"), + ); + const raw = buildDevice(2, ["Eth1", "Lo0"], trailing); + + const dev = deserializeDevice(raw); + expect(dev.interfaces.length).toBe(2); + expect(dev.newInterfaces.length).toBe(2); + expect(dev.newInterfaces[0]!.name).toBe("Eth1"); + expect(dev.newInterfaces[1]!.name).toBe("Lo0"); + expect(dev.newInterfaces[0]!.version).toBe(CURRENT_INTERFACE_VERSION); + for (const ni of dev.newInterfaces) { + const expected = 3 + newInterfaceBody(ni.name).length; + expect(ni.size).toBe(expected); + } + }); + + test("legacy account rebuilds new_interfaces", () => { + const raw = buildDevice(2, ["Eth1", "Lo0"], null); + + const dev = deserializeDevice(raw); + expect(dev.interfaces.length).toBe(2); + expect(dev.newInterfaces.length).toBe(2); + expect(dev.newInterfaces[0]!.name).toBe("Eth1"); + expect(dev.newInterfaces[1]!.name).toBe("Lo0"); + // Rebuilt entries are stamped with the current schema version and zero size. + for (const ni of dev.newInterfaces) { + expect(ni.version).toBe(CURRENT_INTERFACE_VERSION); + expect(ni.size).toBe(0); + } + }); + + test("trailing length mismatch throws", () => { + const trailing = concat(u32(1), newInterfaceSized("Eth1")); + const raw = buildDevice(2, ["Eth1", "Lo0"], trailing); + + expect(() => deserializeDevice(raw)).toThrow( + /length 1 != interfaces length 2/, + ); + }); + + test("future version skips trailing bytes", () => { + // Forge a version=5 element with 8 trailing junk bytes appended past the + // known body. The reader must advance past start+size. + const body = concat( + newInterfaceBody("Future1"), + new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe]), + ); + const sized = newInterfaceSized("Future1", 5, body); + const trailing = concat(u32(1), sized); + const raw = buildDevice(1, ["Future1"], trailing); + + const dev = deserializeDevice(raw); + expect(dev.newInterfaces.length).toBe(1); + expect(dev.newInterfaces[0]!.version).toBe(5); + expect(dev.newInterfaces[0]!.size).toBe(3 + body.length); + expect(dev.newInterfaces[0]!.name).toBe("Future1"); + }); +}); diff --git a/smartcontract/sdk/go/serviceability/client_test.go b/smartcontract/sdk/go/serviceability/client_test.go index 003f164b02..7f7437fc2c 100644 --- a/smartcontract/sdk/go/serviceability/client_test.go +++ b/smartcontract/sdk/go/serviceability/client_test.go @@ -292,7 +292,34 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { ReferenceCount: 1234, UsersCount: 110, MaxUsers: 128, - PubKey: pubkeys[2], + // Legacy fixture has no trailing new_interfaces vec; the rebuild + // path projects each legacy Interface into a NewInterface stamped + // with the current schema version. + NewInterfaces: []Interface{ + { + Version: CurrentInterfaceVersion, + Status: InterfaceStatusPending, + Name: "switch1/1/1", + InterfaceType: InterfaceTypePhysical, + LoopbackType: LoopbackTypeNone, + VlanId: 42, + IpNet: [5]byte{0x0a, 0x01, 0x02, 0x03, 0x1d}, + NodeSegmentIdx: 123, + UserTunnelEndpoint: false, + }, + { + Version: CurrentInterfaceVersion, + Status: InterfaceStatusPending, + Name: "lo0", + InterfaceType: InterfaceTypeLoopback, + LoopbackType: LoopbackTypeVpnv4, + VlanId: 15, + IpNet: [5]byte{0x0a, 0x02, 0x03, 0x04, 0x1d}, + NodeSegmentIdx: 42, + UserTunnelEndpoint: true, + }, + }, + PubKey: pubkeys[2], }, }, Locations: []Location{}, diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index 27b29604d6..80da5cef0c 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -1,6 +1,9 @@ package serviceability -import "log" +import ( + "fmt" + "log" +) func DeserializeGlobalState(reader *ByteReader, gs *GlobalState) { gs.AccountType = AccountType(reader.ReadU8()) @@ -147,6 +150,53 @@ func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { } } +// DeserializeInterfaceSized reads a single size-prefixed NewInterface element. +// +// Wire format: u16 size + u8 version + body, where size includes the 3-byte prefix. +// Forward-compat readers always advance the cursor to start+size after reading the +// known body fields, so unknown future versions are skipped in O(1). +func DeserializeInterfaceSized(reader *ByteReader, iface *Interface) { + start := reader.GetOffset() + 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. + iface.Status = InterfaceStatus(reader.ReadU8()) + iface.Name = reader.ReadString() + iface.InterfaceType = InterfaceType(reader.ReadU8()) + iface.InterfaceCYOA = InterfaceCYOA(reader.ReadU8()) + iface.InterfaceDIA = InterfaceDIA(reader.ReadU8()) + iface.LoopbackType = LoopbackType(reader.ReadU8()) + iface.Bandwidth = reader.ReadU64() + iface.Cir = reader.ReadU64() + iface.Mtu = reader.ReadU16() + iface.RoutingMode = RoutingMode(reader.ReadU8()) + iface.VlanId = reader.ReadU16() + iface.IpNet = reader.ReadNetworkV4() + iface.NodeSegmentIdx = reader.ReadU16() + iface.UserTunnelEndpoint = (reader.ReadU8() != 0) + segCount := reader.ReadU32() + iface.FlexAlgoNodeSegments = make([]FlexAlgoNodeSegment, 0, segCount) + for i := uint32(0); i < segCount; i++ { + // Defensive guard against garbage segCount when the body is shorter than + // expected (e.g. older size-prefixed encoding without flex segments). + if reader.Remaining() < 34 { // 32 (pubkey) + 2 (u16) + break + } + iface.FlexAlgoNodeSegments = append(iface.FlexAlgoNodeSegments, FlexAlgoNodeSegment{ + Topology: reader.ReadPubkey(), + NodeSegmentIdx: reader.ReadU16(), + }) + } + + // Advance to start+size regardless of how many body bytes we consumed. + target := start + int(iface.Size) + if cur := reader.GetOffset(); cur < target { + reader.Skip(target - cur) + } +} + func DeserializeDevice(reader *ByteReader, dev *Device) { dev.AccountType = AccountType(reader.ReadU8()) dev.Owner = reader.ReadPubkey() @@ -185,6 +235,31 @@ func DeserializeDevice(reader *ByteReader, dev *Device) { dev.ReservedSeats = reader.ReadU16() dev.MulticastPublishersCount = reader.ReadU16() dev.MaxMulticastPublishers = reader.ReadU16() + + // Trailing new_interfaces vec (size-prefixed). Empty trailing => rebuild from legacy. + // Length mismatch => surface via dev.DeserializeError without aborting earlier fields. + if reader.Remaining() == 0 { + dev.NewInterfaces = make([]Interface, len(dev.Interfaces)) + for i, legacy := range dev.Interfaces { + ni := legacy + ni.Version = CurrentInterfaceVersion + ni.Size = 0 + dev.NewInterfaces[i] = ni + } + } else { + newLen := reader.ReadU32() + if int(newLen) != len(dev.Interfaces) { + dev.DeserializeError = fmt.Errorf( + "DeserializeDevice: new_interfaces length %d != interfaces length %d", + newLen, len(dev.Interfaces), + ) + return + } + dev.NewInterfaces = make([]Interface, newLen) + for i := uint32(0); i < newLen; i++ { + DeserializeInterfaceSized(reader, &dev.NewInterfaces[i]) + } + } // Note: dev.PubKey is set separately in client.go after deserialization } diff --git a/smartcontract/sdk/go/serviceability/deserialize_test.go b/smartcontract/sdk/go/serviceability/deserialize_test.go new file mode 100644 index 0000000000..a8714360e3 --- /dev/null +++ b/smartcontract/sdk/go/serviceability/deserialize_test.go @@ -0,0 +1,264 @@ +package serviceability_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/malbeclabs/doublezero/smartcontract/sdk/go/serviceability" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// deviceBuilder assembles a Borsh-style Device byte stream for tests. +// +// Wire format mirrors smartcontract/programs/doublezero-serviceability::state::device. +// Field order through max_multicast_publishers, then optionally a trailing +// new_interfaces vec where each element is laid out as: +// +// u16 size (incl. 3-byte prefix) | u8 version | body +// +// Body layout matches Rust NewInterface::serialize_body — see that function for +// the canonical ordering. +type deviceBuilder struct { + buf bytes.Buffer +} + +func (b *deviceBuilder) writeU8(v uint8) { b.buf.WriteByte(v) } +func (b *deviceBuilder) writeBool(v bool) { b.writeU8(map[bool]uint8{false: 0, true: 1}[v]) } +func (b *deviceBuilder) writeU16(v uint16) { _ = binary.Write(&b.buf, binary.LittleEndian, v) } +func (b *deviceBuilder) writeU32(v uint32) { _ = binary.Write(&b.buf, binary.LittleEndian, v) } +func (b *deviceBuilder) writeU64(v uint64) { _ = binary.Write(&b.buf, binary.LittleEndian, v) } +func (b *deviceBuilder) writeBytes(v []byte) { + b.buf.Write(v) +} +func (b *deviceBuilder) writeU128(highLow [2]uint64) { + // Matches ByteReader.ReadU128 in this package: high bytes first, then low. + b.writeU64(highLow[0]) + b.writeU64(highLow[1]) +} +func (b *deviceBuilder) writeString(s string) { + b.writeU32(uint32(len(s))) + b.buf.WriteString(s) +} +func (b *deviceBuilder) writePubkey(p [32]byte) { b.buf.Write(p[:]) } +func (b *deviceBuilder) writeIPv4(p [4]byte) { b.buf.Write(p[:]) } +func (b *deviceBuilder) writeNetworkV4(p [5]byte) { + b.buf.Write(p[:]) +} + +// writeDeviceHeader writes everything from account_type through mgmt_vrf +// using zero/default values, then runs an explicit interface count + body +// callback before writing the trailing scalar/vec fields. +func (b *deviceBuilder) writeDevice(numIfaces uint32, writeIfaces func(*deviceBuilder), trailing func(*deviceBuilder)) { + var zeroPK [32]byte + b.writeU8(uint8(serviceability.DeviceType)) // account_type + b.writePubkey(zeroPK) // owner + b.writeU128([2]uint64{0, 1}) // index + b.writeU8(255) // bump_seed + b.writePubkey(zeroPK) // location_pk + b.writePubkey(zeroPK) // exchange_pk + b.writeU8(0) // device_type + b.writeIPv4([4]byte{1, 2, 3, 4}) // public_ip + b.writeU8(1) // status (Activated) + b.writeString("dev-test") // code + b.writeU32(0) // dz_prefixes (empty) + b.writePubkey(zeroPK) // metrics_publisher_pk + b.writePubkey(zeroPK) // contributor_pk + b.writeString("default") // mgmt_vrf + + b.writeU32(numIfaces) + if writeIfaces != nil { + writeIfaces(b) + } + + b.writeU32(0) // reference_count + b.writeU16(0) // users_count + b.writeU16(0) // max_users + b.writeU8(0) // device_health (Unknown) + b.writeU8(0) // device_desired_status (Pending) + b.writeU16(0) // unicast_users_count + b.writeU16(0) // multicast_subscribers_count + b.writeU16(0) // max_unicast_users + b.writeU16(0) // max_multicast_subscribers + b.writeU16(0) // reserved_seats + b.writeU16(0) // multicast_publishers_count + b.writeU16(0) // max_multicast_publishers + + if trailing != nil { + trailing(b) + } +} + +// writeLegacyInterfaceV2 appends a single legacy enum-encoded V2 interface +// (discriminant 1) with caller-provided name. Other fields are zeroed. +func writeLegacyInterfaceV2(b *deviceBuilder, name string) { + b.writeU8(1) // enum disc: V2 + b.writeU8(0) // status + b.writeString(name) // name + b.writeU8(0) // interface_type + b.writeU8(0) // interface_cyoa + b.writeU8(0) // interface_dia + b.writeU8(0) // loopback_type + b.writeU64(0) // bandwidth + b.writeU64(0) // cir + b.writeU16(0) // mtu + b.writeU8(0) // routing_mode + b.writeU16(0) // vlan_id + b.writeNetworkV4([5]byte{}) // ip_net + b.writeU16(0) // node_segment_idx + b.writeBool(false) // user_tunnel_endpoint +} + +// newInterfaceBody returns the body bytes (no size/version prefix) for a +// minimal V4 NewInterface element with caller-provided name. +func newInterfaceBody(name string) []byte { + var body bytes.Buffer + body.WriteByte(0) // status + _ = binary.Write(&body, binary.LittleEndian, uint32(len(name))) + body.WriteString(name) + body.WriteByte(0) // interface_type + body.WriteByte(0) // interface_cyoa + body.WriteByte(0) // interface_dia + body.WriteByte(0) // loopback_type + _ = binary.Write(&body, binary.LittleEndian, uint64(0)) // bandwidth + _ = binary.Write(&body, binary.LittleEndian, uint64(0)) // cir + _ = binary.Write(&body, binary.LittleEndian, uint16(0)) // mtu + body.WriteByte(0) // routing_mode + _ = binary.Write(&body, binary.LittleEndian, uint16(0)) // vlan_id + body.Write(make([]byte, 5)) // ip_net + _ = binary.Write(&body, binary.LittleEndian, uint16(0)) // node_segment_idx + body.WriteByte(0) // user_tunnel_endpoint + _ = binary.Write(&body, binary.LittleEndian, uint32(0)) // flex_algo_node_segments len = 0 + return body.Bytes() +} + +// writeNewInterfaceSized appends a single size-prefixed NewInterface (version 4) +// to the builder. size = 3 + len(body); the size header is included in size. +func writeNewInterfaceSized(b *deviceBuilder, name string) { + body := newInterfaceBody(name) + size := uint16(3 + len(body)) + b.writeU16(size) + b.writeU8(serviceability.CurrentInterfaceVersion) // version = 4 + b.writeBytes(body) +} + +func TestDeserializeInterfaceSized_PopulatedTrailingVec(t *testing.T) { + // Cross-language framing assertion: a NewInterface with empty name has body + // length = 1+4+1+1+1+1+8+8+2+1+2+5+2+1+4 = 42, so size = 3+42 = 45. + const expectedSizeEmptyName = 45 + + var b deviceBuilder + b.writeDevice(2, + func(bb *deviceBuilder) { + writeLegacyInterfaceV2(bb, "Eth1") + writeLegacyInterfaceV2(bb, "Lo0") + }, + func(bb *deviceBuilder) { + bb.writeU32(2) // new_interfaces vec length + writeNewInterfaceSized(bb, "Eth1") + writeNewInterfaceSized(bb, "Lo0") + }, + ) + + r := serviceability.NewByteReader(b.buf.Bytes()) + var dev serviceability.Device + serviceability.DeserializeDevice(r, &dev) + require.NoError(t, dev.DeserializeError) + + require.Len(t, dev.Interfaces, 2) + require.Len(t, dev.NewInterfaces, 2) + assert.Equal(t, "Eth1", dev.NewInterfaces[0].Name) + assert.Equal(t, "Lo0", dev.NewInterfaces[1].Name) + assert.Equal(t, uint8(serviceability.CurrentInterfaceVersion), dev.NewInterfaces[0].Version) + + // The size field on NewInterface includes the 2-byte size + 1-byte version + // + body. For an empty-flex-segs body with name "Eth1": 4+4+...; verified + // against the expected-empty-name baseline below. + emptyBody := newInterfaceBody("") + assert.Equal(t, expectedSizeEmptyName, 3+len(emptyBody)) + for i := range dev.NewInterfaces { + expected := uint16(3 + len(newInterfaceBody(dev.NewInterfaces[i].Name))) + assert.Equal(t, expected, dev.NewInterfaces[i].Size, "size mismatch on element %d", i) + } +} + +func TestDeserializeDevice_LegacyAccountRebuildsNewInterfaces(t *testing.T) { + var b deviceBuilder + b.writeDevice(2, + func(bb *deviceBuilder) { + writeLegacyInterfaceV2(bb, "Eth1") + writeLegacyInterfaceV2(bb, "Lo0") + }, + nil, // no trailing bytes -> legacy fallback + ) + + r := serviceability.NewByteReader(b.buf.Bytes()) + var dev serviceability.Device + serviceability.DeserializeDevice(r, &dev) + require.NoError(t, dev.DeserializeError) + + require.Len(t, dev.Interfaces, 2) + require.Len(t, dev.NewInterfaces, 2) + assert.Equal(t, "Eth1", dev.NewInterfaces[0].Name) + assert.Equal(t, "Lo0", dev.NewInterfaces[1].Name) + // Rebuilt entries are stamped with the current schema version and zero + // size (callers don't need on-disk size for a rebuild). + for _, ni := range dev.NewInterfaces { + assert.Equal(t, uint8(serviceability.CurrentInterfaceVersion), ni.Version) + assert.Equal(t, uint16(0), ni.Size) + } +} + +func TestDeserializeDevice_TrailingLengthMismatchSetsError(t *testing.T) { + var b deviceBuilder + b.writeDevice(2, + func(bb *deviceBuilder) { + writeLegacyInterfaceV2(bb, "Eth1") + writeLegacyInterfaceV2(bb, "Lo0") + }, + func(bb *deviceBuilder) { + bb.writeU32(1) // declared 1 new_interface but legacy has 2 -> mismatch + writeNewInterfaceSized(bb, "Eth1") + }, + ) + + r := serviceability.NewByteReader(b.buf.Bytes()) + var dev serviceability.Device + serviceability.DeserializeDevice(r, &dev) + require.Error(t, dev.DeserializeError) + assert.Contains(t, dev.DeserializeError.Error(), "length 1 != interfaces length 2") +} + +func TestDeserializeInterfaceSized_FutureVersionSkipsTrailingBytes(t *testing.T) { + // Forge an element with version=5 and 8 trailing junk bytes appended past + // the known body. The reader should advance past start+size and leave the + // next element readable. + body := newInterfaceBody("Future1") + junk := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE} + full := append(body, junk...) + size := uint16(3 + len(full)) + + var b deviceBuilder + b.writeDevice(1, + func(bb *deviceBuilder) { + writeLegacyInterfaceV2(bb, "Future1") + }, + func(bb *deviceBuilder) { + bb.writeU32(1) + bb.writeU16(size) + bb.writeU8(5) // version = 5 (future) + bb.writeBytes(full) + }, + ) + + r := serviceability.NewByteReader(b.buf.Bytes()) + var dev serviceability.Device + serviceability.DeserializeDevice(r, &dev) + require.NoError(t, dev.DeserializeError) + require.Len(t, dev.NewInterfaces, 1) + assert.Equal(t, uint8(5), dev.NewInterfaces[0].Version) + assert.Equal(t, size, dev.NewInterfaces[0].Size) + // Body fields up to known shape are still parsed. + assert.Equal(t, "Future1", dev.NewInterfaces[0].Name) +} diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 0b56ef3c87..c73d37a7d3 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -396,6 +396,9 @@ type FlexAlgoNodeSegment struct { } type Interface struct { + // Size is the on-disk byte length of the size-prefixed encoding (u16 size + u8 version + body). + // Populated only when read via DeserializeInterfaceSized; zero for legacy enum reads. + Size uint16 `json:",omitempty"` Version uint8 Status InterfaceStatus Name string @@ -440,7 +443,9 @@ func (i Interface) MarshalJSON() ([]byte, error) { return json.Marshal(jsonIface) } -const CurrentInterfaceVersion = 3 +// CurrentInterfaceVersion is the on-wire schema version of the size-prefixed +// NewInterface format (matching Rust's CURRENT_INTERFACE_SCHEMA_VERSION). +const CurrentInterfaceVersion = 4 type Device struct { AccountType AccountType @@ -470,7 +475,16 @@ type Device struct { ReservedSeats uint16 `influx:"field,reserved_seats"` MulticastPublishersCount uint16 `influx:"field,multicast_publishers_count"` MaxMulticastPublishers uint16 `influx:"field,max_multicast_publishers"` - PubKey [32]byte `influx:"tag,pubkey,pubkey"` + // NewInterfaces is the trailing size-prefixed vec parallel to Interfaces. For legacy + // accounts (no trailing bytes), this is rebuilt from Interfaces by DeserializeDevice. + // When populated from the wire, len(NewInterfaces) == len(Interfaces) is enforced. + NewInterfaces []Interface `influx:"-" json:",omitempty"` + // DeserializeError is set when DeserializeDevice encounters a recoverable but + // account-malformed condition (e.g. trailing new_interfaces length mismatch with + // the legacy interfaces vec). Consumers should check this before trusting the + // deserialized fields. + DeserializeError error `influx:"-" json:"-"` + PubKey [32]byte `influx:"tag,pubkey,pubkey"` } func (d Device) MarshalJSON() ([]byte, error) {