From 7e9d258c6901c848c3133120637ac8dac91b63e3 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 15:57:42 -0500 Subject: [PATCH 01/54] smartcontract: add AdminGroupBits ResourceExtension with UNICAST-DRAINED pre-mark --- .../doublezero-serviceability/src/pda.rs | 10 ++- .../src/processors/resource/mod.rs | 10 +++ .../doublezero-serviceability/src/resource.rs | 2 + .../doublezero-serviceability/src/seeds.rs | 1 + .../tests/topology_test.rs | 71 +++++++++++++++++++ 5 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/tests/topology_test.rs diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index 7e8e0e1bcf..dbbef0adc9 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -4,8 +4,9 @@ use solana_program::pubkey::Pubkey; use crate::{ seeds::{ - SEED_ACCESS_PASS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, SEED_DEVICE_TUNNEL_BLOCK, - SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_INDEX, SEED_LINK, + SEED_ACCESS_PASS, SEED_ADMIN_GROUP_BITS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, + SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, + SEED_INDEX, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TUNNEL_IDS, SEED_USER, SEED_USER_TUNNEL_BLOCK, @@ -182,5 +183,10 @@ pub fn get_resource_extension_pda( Pubkey::find_program_address(&[SEED_PREFIX, SEED_VRF_IDS], program_id); (pda, bump_seed, SEED_VRF_IDS) } + crate::resource::ResourceType::AdminGroupBits => { + let (pda, bump_seed) = + Pubkey::find_program_address(&[SEED_PREFIX, SEED_ADMIN_GROUP_BITS], program_id); + (pda, bump_seed, SEED_ADMIN_GROUP_BITS) + } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs index 6838ba8c47..8f9dc19a1f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs @@ -69,6 +69,7 @@ pub fn get_resource_extension_range( ResourceType::LinkIds => ResourceExtensionRange::IdRange(0, 65535), ResourceType::SegmentRoutingIds => ResourceExtensionRange::IdRange(1, 65535), ResourceType::VrfIds => ResourceExtensionRange::IdRange(1, 1024), + ResourceType::AdminGroupBits => ResourceExtensionRange::IdRange(0, 127), } } @@ -171,6 +172,15 @@ pub fn create_resource( resource.allocate(1)?; // Allocates index 0 } + // Pre-mark bit 1 (UNICAST-DRAINED) so it is never allocated to a user topology. + // IS-IS flex-algo admin-group bit 1 is reserved for the UNICAST-DRAINED topology + // and must never be reused. + if let ResourceType::AdminGroupBits = resource_type { + let mut buffer = resource_account.data.borrow_mut(); + let mut resource = ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; + resource.allocate_specific(&crate::resource::IdOrIp::Id(1))?; + } + Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/resource.rs b/smartcontract/programs/doublezero-serviceability/src/resource.rs index 79de501b0b..9b2bdf95b7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/resource.rs +++ b/smartcontract/programs/doublezero-serviceability/src/resource.rs @@ -15,6 +15,7 @@ pub enum ResourceType { LinkIds, SegmentRoutingIds, VrfIds, + AdminGroupBits, } impl fmt::Display for ResourceType { @@ -29,6 +30,7 @@ impl fmt::Display for ResourceType { ResourceType::LinkIds => write!(f, "LinkIds"), ResourceType::SegmentRoutingIds => write!(f, "SegmentRoutingIds"), ResourceType::VrfIds => write!(f, "VrfIds"), + ResourceType::AdminGroupBits => write!(f, "AdminGroupBits"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index 4ef9626dd9..60ba9e6547 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -21,4 +21,5 @@ pub const SEED_LINK_IDS: &[u8] = b"linkids"; pub const SEED_SEGMENT_ROUTING_IDS: &[u8] = b"segmentroutingids"; pub const SEED_VRF_IDS: &[u8] = b"vrfids"; pub const SEED_PERMISSION: &[u8] = b"permission"; +pub const SEED_ADMIN_GROUP_BITS: &[u8] = b"admingroupbits"; pub const SEED_INDEX: &[u8] = b"index"; diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs new file mode 100644 index 0000000000..d177e25ea7 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -0,0 +1,71 @@ +//! Integration tests for AdminGroupBits ResourceExtension (RFC-18 / Link Classification). + +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::get_resource_extension_pda, + processors::resource::create::ResourceCreateArgs, + resource::{IdOrIp, ResourceType}, +}; +use solana_program_test::*; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; + +mod test_helpers; +use test_helpers::*; + +#[tokio::test] +async fn test_admin_group_bits_create_and_pre_mark() { + println!("[TEST] test_admin_group_bits_create_and_pre_mark"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (resource_pubkey, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create the AdminGroupBits resource extension + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(resource_pubkey, false), + AccountMeta::new(Pubkey::default(), false), // associated_account (not used) + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + &payer, + ) + .await; + + // Verify the account was created and has data + let account = banks_client + .get_account(resource_pubkey) + .await + .unwrap() + .expect("AdminGroupBits account should exist"); + + assert!( + !account.data.is_empty(), + "AdminGroupBits account should have non-empty data" + ); + + // Verify bit 1 (UNICAST-DRAINED) is pre-marked + let resource = get_resource_extension_data(&mut banks_client, resource_pubkey) + .await + .expect("AdminGroupBits resource extension should be deserializable"); + + let allocated = resource.iter_allocated(); + assert_eq!(allocated.len(), 1, "exactly one bit should be pre-marked"); + assert_eq!( + allocated[0], + IdOrIp::Id(1), + "bit 1 (UNICAST-DRAINED) should be pre-marked" + ); + + println!("[PASS] test_admin_group_bits_create_and_pre_mark"); +} From 60b0f729df406154bd26a8590d1b7dba06583463 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 16:16:15 -0500 Subject: [PATCH 02/54] smartcontract: add TopologyInfo, FlexAlgoNodeSegment state structs; InterfaceV3 --- client/doublezero/src/dzd_latency.rs | 3 +- .../doublezero-serviceability/src/pda.rs | 8 +- .../src/processors/device/interface/create.rs | 1 + .../doublezero-serviceability/src/seeds.rs | 1 + .../src/state/accountdata.rs | 8 +- .../src/state/accounttype.rs | 3 + .../src/state/device.rs | 3 + .../src/state/interface.rs | 125 +++++++++++++++++- .../src/state/mod.rs | 1 + .../src/state/topology.rs | 68 ++++++++++ .../tests/topology_test.rs | 43 +++++- 11 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/state/topology.rs diff --git a/client/doublezero/src/dzd_latency.rs b/client/doublezero/src/dzd_latency.rs index 3b4e09fcf5..cec4b93214 100644 --- a/client/doublezero/src/dzd_latency.rs +++ b/client/doublezero/src/dzd_latency.rs @@ -259,7 +259,7 @@ mod tests { .into_iter() .enumerate() .map(|(i, ip)| { - Interface::V2(CurrentInterfaceVersion { + Interface::V3(CurrentInterfaceVersion { status: InterfaceStatus::Activated, name: format!("Loopback{}", i), interface_type: InterfaceType::Loopback, @@ -274,6 +274,7 @@ mod tests { ip_net: NetworkV4::new(ip, 32).unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }) }) .collect(); diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index dbbef0adc9..eb90cd03c5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -9,8 +9,8 @@ use crate::{ SEED_INDEX, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, - SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TUNNEL_IDS, SEED_USER, SEED_USER_TUNNEL_BLOCK, - SEED_VRF_IDS, + SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, SEED_USER, + SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, }, state::user::UserType, }; @@ -117,6 +117,10 @@ pub fn get_index_pda(program_id: &Pubkey, entity_seed: &[u8], key: &str) -> (Pub ) } +pub fn get_topology_pda(program_id: &Pubkey, name: &str) -> (Pubkey, u8) { + Pubkey::find_program_address(&[SEED_PREFIX, SEED_TOPOLOGY, name.as_bytes()], program_id) +} + pub fn get_resource_extension_pda( program_id: &Pubkey, resource_type: crate::resource::ResourceType, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs index 6e2e0453ce..3abe78ced9 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs @@ -236,6 +236,7 @@ pub fn process_create_device_interface( ip_net, node_segment_idx, user_tunnel_endpoint: value.user_tunnel_endpoint, + flex_algo_node_segments: vec![], } .to_interface(), ); diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index 60ba9e6547..d4d98eae6b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -23,3 +23,4 @@ pub const SEED_VRF_IDS: &[u8] = b"vrfids"; pub const SEED_PERMISSION: &[u8] = b"permission"; pub const SEED_ADMIN_GROUP_BITS: &[u8] = b"admingroupbits"; pub const SEED_INDEX: &[u8] = b"index"; +pub const SEED_TOPOLOGY: &[u8] = b"topology"; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs index 89fb6ac3c8..50a88888de 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs @@ -5,7 +5,7 @@ use crate::{ exchange::Exchange, globalconfig::GlobalConfig, globalstate::GlobalState, index::Index, link::Link, location::Location, multicastgroup::MulticastGroup, permission::Permission, programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, - user::User, + topology::TopologyInfo, user::User, }, }; use solana_program::program_error::ProgramError; @@ -30,6 +30,7 @@ pub enum AccountData { Tenant(Tenant), Permission(Permission), Index(Index), + Topology(TopologyInfo), } impl AccountData { @@ -51,6 +52,7 @@ impl AccountData { AccountData::Tenant(_) => "Tenant", AccountData::Permission(_) => "Permission", AccountData::Index(_) => "Index", + AccountData::Topology(_) => "Topology", } } @@ -72,6 +74,7 @@ impl AccountData { AccountData::Tenant(tenant) => tenant.to_string(), AccountData::Permission(permission) => permission.to_string(), AccountData::Index(index) => index.to_string(), + AccountData::Topology(topology) => topology.to_string(), } } @@ -236,6 +239,9 @@ impl TryFrom<&[u8]> for AccountData { bytes as &[u8], )?)), AccountType::Index => Ok(AccountData::Index(Index::try_from(bytes as &[u8])?)), + AccountType::Topology => Ok(AccountData::Topology(TopologyInfo::try_from( + bytes as &[u8], + )?)), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs index 24e8430bab..a4e8501aa2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs @@ -24,6 +24,7 @@ pub enum AccountType { Tenant = 13, Permission = 15, Index = 16, + Topology = 17, } pub trait AccountTypeInfo { @@ -52,6 +53,7 @@ impl From for AccountType { 13 => AccountType::Tenant, 15 => AccountType::Permission, 16 => AccountType::Index, + 17 => AccountType::Topology, _ => AccountType::None, } } @@ -76,6 +78,7 @@ impl fmt::Display for AccountType { AccountType::Tenant => write!(f, "tenant"), AccountType::Permission => write!(f, "permission"), AccountType::Index => write!(f, "index"), + AccountType::Topology => write!(f, "topology"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/device.rs b/smartcontract/programs/doublezero-serviceability/src/state/device.rs index b39bec5be5..e4b6824c74 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/device.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/device.rs @@ -1049,6 +1049,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(); let val = Device { @@ -1119,6 +1120,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -1136,6 +1138,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 24, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index c82811f9fb..9f21784ddd 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -394,6 +394,113 @@ 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, // 1 + pub name: String, // 4 + len + pub interface_type: InterfaceType, // 1 + pub interface_cyoa: InterfaceCYOA, // 1 + pub interface_dia: InterfaceDIA, // 1 + pub loopback_type: LoopbackType, // 1 + pub bandwidth: u64, // 8 + pub cir: u64, // 8 + pub mtu: u16, // 2 + pub routing_mode: RoutingMode, // 1 + pub vlan_id: u16, // 2 + pub ip_net: NetworkV4, // 4 IPv4 address + 1 subnet mask + pub node_segment_idx: u16, // 2 + pub user_tunnel_endpoint: bool, // 1 + 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) -> Interface { + Interface::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 TryFrom<&[u8]> for InterfaceV3 { + type Error = ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + Ok(Self { + status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + interface_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + interface_cyoa: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + interface_dia: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + loopback_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + bandwidth: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + cir: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + mtu: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + routing_mode: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + vlan_id: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + ip_net: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + node_segment_idx: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + user_tunnel_endpoint: { + let val: u8 = BorshDeserialize::deserialize(&mut data).unwrap_or_default(); + val != 0 + }, + flex_algo_node_segments: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + }) + } +} + +impl TryFrom<&InterfaceV2> for InterfaceV3 { + type Error = ProgramError; + + fn try_from(data: &InterfaceV2) -> 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, + flex_algo_node_segments: vec![], + }) + } +} + +impl Default for InterfaceV3 { + fn default() -> Self { + Self { + status: InterfaceStatus::Pending, + name: String::default(), + interface_type: InterfaceType::Invalid, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + bandwidth: 0, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + ip_net: NetworkV4::default(), + node_segment_idx: 0, + user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], + } + } +} + #[repr(u8)] #[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -401,15 +508,20 @@ impl Default for InterfaceV2 { pub enum Interface { V1(InterfaceV1), V2(InterfaceV2), + V3(InterfaceV3), } -pub type CurrentInterfaceVersion = InterfaceV2; +pub type CurrentInterfaceVersion = InterfaceV3; impl Interface { pub fn into_current_version(&self) -> CurrentInterfaceVersion { match self { - Interface::V1(v1) => v1.try_into().unwrap_or_default(), - Interface::V2(v2) => v2.clone(), + Interface::V1(v1) => { + let v2: InterfaceV2 = v1.try_into().unwrap_or_default(); + InterfaceV3::try_from(&v2).unwrap_or_default() + } + Interface::V2(v2) => InterfaceV3::try_from(v2).unwrap_or_default(), + Interface::V3(v3) => v3.clone(), } } @@ -417,6 +529,7 @@ impl Interface { let base_size = match self { Interface::V1(v1) => v1.size(), Interface::V2(v2) => v2.size(), + Interface::V3(v3) => v3.size(), }; base_size + 1 // +1 for the enum discriminant } @@ -480,8 +593,10 @@ impl TryFrom<&[u8]> for Interface { fn try_from(mut data: &[u8]) -> Result { match BorshDeserialize::deserialize(&mut data) { - Ok(0) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), - _ => Ok(Interface::V1(InterfaceV1::default())), // Default case + Ok(0u8) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), + Ok(1u8) => Ok(Interface::V2(InterfaceV2::try_from(data)?)), + Ok(2u8) => Ok(Interface::V3(InterfaceV3::try_from(data)?)), + _ => Ok(Interface::V3(InterfaceV3::default())), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs index bbf02d13a1..dda96c197d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs @@ -16,4 +16,5 @@ pub mod permission; pub mod programconfig; pub mod resource_extension; pub mod tenant; +pub mod topology; pub mod user; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs new file mode 100644 index 0000000000..8e02ea58b7 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs @@ -0,0 +1,68 @@ +use crate::state::accounttype::AccountType; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::pubkey::Pubkey; + +#[repr(u8)] +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, Copy, PartialEq, Default)] +#[borsh(use_discriminant = true)] +pub enum TopologyConstraint { + #[default] + IncludeAny = 0, + Exclude = 1, +} + +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +pub struct TopologyInfo { + pub account_type: AccountType, + pub owner: Pubkey, + pub bump_seed: u8, + pub name: String, // max 32 bytes enforced on create + pub admin_group_bit: u8, // 0–127 + pub flex_algo_number: u8, // always 128 + admin_group_bit + pub constraint: TopologyConstraint, +} + +impl std::fmt::Display for TopologyInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "name={} bit={} algo={} color={} constraint={:?}", + self.name, + self.admin_group_bit, + self.flex_algo_number, + self.admin_group_bit as u16 + 1, + self.constraint + ) + } +} + +impl TryFrom<&[u8]> for TopologyInfo { + type Error = solana_program::program_error::ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + Ok(Self { + account_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + owner: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + bump_seed: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + admin_group_bit: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + flex_algo_number: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + constraint: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + }) + } +} + +impl TryFrom<&solana_program::account_info::AccountInfo<'_>> for TopologyInfo { + type Error = solana_program::program_error::ProgramError; + + fn try_from(account: &solana_program::account_info::AccountInfo) -> Result { + Self::try_from(&account.data.borrow()[..]) + } +} + +/// Flex-algo node segment entry on a Vpnv4 loopback Interface account. +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +pub struct FlexAlgoNodeSegment { + pub topology: Pubkey, // TopologyInfo PDA pubkey + pub node_segment_idx: u16, // allocated from SegmentRoutingIds ResourceExtension +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index d177e25ea7..1b637510ba 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1,4 +1,4 @@ -//! Integration tests for AdminGroupBits ResourceExtension (RFC-18 / Link Classification). +//! Tests for TopologyInfo, FlexAlgoNodeSegment, and InterfaceV3 (RFC-18 / Link Classification). use doublezero_serviceability::{ instructions::DoubleZeroInstruction, @@ -69,3 +69,44 @@ async fn test_admin_group_bits_create_and_pre_mark() { println!("[PASS] test_admin_group_bits_create_and_pre_mark"); } + +#[test] +fn test_topology_info_roundtrip() { + use doublezero_serviceability::state::{ + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + + let info = TopologyInfo { + account_type: AccountType::Topology, + owner: solana_sdk::pubkey::Pubkey::new_unique(), + bump_seed: 42, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + let bytes = borsh::to_vec(&info).unwrap(); + let decoded = TopologyInfo::try_from(bytes.as_slice()).unwrap(); + assert_eq!(decoded, info); +} + +#[test] +fn test_flex_algo_node_segment_roundtrip() { + use doublezero_serviceability::state::topology::FlexAlgoNodeSegment; + + let seg = FlexAlgoNodeSegment { + topology: solana_sdk::pubkey::Pubkey::new_unique(), + node_segment_idx: 1001, + }; + let bytes = borsh::to_vec(&seg).unwrap(); + let decoded: FlexAlgoNodeSegment = borsh::from_slice(&bytes).unwrap(); + assert_eq!(decoded.node_segment_idx, 1001); +} + +#[test] +fn test_interface_v3_defaults_flex_algo_node_segments_empty() { + use doublezero_serviceability::state::interface::InterfaceV3; + let iface = InterfaceV3::default(); + assert!(iface.flex_algo_node_segments.is_empty()); +} From 69000d4dbc7528074a8a8c1c64ee528519024512 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 16:57:56 -0500 Subject: [PATCH 03/54] smartcontract: add TopologyCreate instruction with admin-group bit allocation Implements the TopologyCreate instruction (variant 104) for the doublezero-serviceability program. The instruction creates a TopologyInfo PDA, allocates the lowest available bit from the AdminGroupBits ResourceExtension (skipping pre-reserved bit 1 / UNICAST-DRAINED), derives flex_algo_number = 128 + admin_group_bit, and optionally backfills Vpnv4 loopback interfaces on Device accounts with a FlexAlgoNodeSegment entry. Also adds stub TopologyDelete (105) and TopologyClear (106) instructions for future implementation, and fixes missing flex_algo_node_segments field in CLI test fixtures for InterfaceV3. --- .../cli/src/device/interface/create.rs | 2 + .../cli/src/device/interface/delete.rs | 2 + smartcontract/cli/src/device/interface/get.rs | 1 + .../cli/src/device/interface/list.rs | 2 + .../cli/src/device/interface/update.rs | 5 + .../src/entrypoint.rs | 13 + .../src/instructions.rs | 33 +- .../src/processors/mod.rs | 1 + .../src/processors/topology/clear.rs | 16 + .../src/processors/topology/create.rs | 199 ++++++ .../src/processors/topology/delete.rs | 16 + .../src/processors/topology/mod.rs | 3 + .../src/state/accountdata.rs | 8 + .../src/state/topology.rs | 17 +- .../tests/topology_test.rs | 650 +++++++++++++++++- 15 files changed, 958 insertions(+), 10 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs diff --git a/smartcontract/cli/src/device/interface/create.rs b/smartcontract/cli/src/device/interface/create.rs index c3408d7ed1..975898d4b1 100644 --- a/smartcontract/cli/src/device/interface/create.rs +++ b/smartcontract/cli/src/device/interface/create.rs @@ -223,6 +223,7 @@ mod tests { ip_net: "185.189.47.80/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -321,6 +322,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/cli/src/device/interface/delete.rs b/smartcontract/cli/src/device/interface/delete.rs index 80dbe70bc1..a3a0f674b5 100644 --- a/smartcontract/cli/src/device/interface/delete.rs +++ b/smartcontract/cli/src/device/interface/delete.rs @@ -109,6 +109,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 12, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -126,6 +127,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 13, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/cli/src/device/interface/get.rs b/smartcontract/cli/src/device/interface/get.rs index 7e512fce2b..44c26de1fe 100644 --- a/smartcontract/cli/src/device/interface/get.rs +++ b/smartcontract/cli/src/device/interface/get.rs @@ -136,6 +136,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/cli/src/device/interface/list.rs b/smartcontract/cli/src/device/interface/list.rs index 86c2b6a26a..c4df9c9c1d 100644 --- a/smartcontract/cli/src/device/interface/list.rs +++ b/smartcontract/cli/src/device/interface/list.rs @@ -164,6 +164,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 12, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -182,6 +183,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 13, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/cli/src/device/interface/update.rs b/smartcontract/cli/src/device/interface/update.rs index 5c31ab90a0..8dee27ee7b 100644 --- a/smartcontract/cli/src/device/interface/update.rs +++ b/smartcontract/cli/src/device/interface/update.rs @@ -242,6 +242,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -259,6 +260,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], @@ -376,6 +378,7 @@ mod tests { ip_net: "10.0.0.1/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -424,6 +427,7 @@ mod tests { ip_net: "185.189.47.80/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -520,6 +524,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 555fac6e0f..bb65df6159 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -97,6 +97,10 @@ use crate::{ remove_administrator::process_remove_administrator_tenant, update::process_update_tenant, update_payment_status::process_update_payment_status, }, + topology::{ + clear::process_topology_clear, create::process_topology_create, + delete::process_topology_delete, + }, user::{ activate::process_activate_user, ban::process_ban_user, check_access_pass::process_check_access_pass_user, @@ -431,6 +435,15 @@ pub fn process_instruction( DoubleZeroInstruction::SetUserBGPStatus(value) => { process_set_bgp_status_user(program_id, accounts, &value)? } + DoubleZeroInstruction::CreateTopology(value) => { + process_topology_create(program_id, accounts, &value)? + } + DoubleZeroInstruction::DeleteTopology(value) => { + process_topology_delete(program_id, accounts, &value)? + } + DoubleZeroInstruction::ClearTopology(value) => { + process_topology_clear(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index fbd60d4b1f..d288ae8b69 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -80,6 +80,7 @@ use crate::processors::{ delete::TenantDeleteArgs, remove_administrator::TenantRemoveAdministratorArgs, update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, }, + topology::{clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs}, user::{ activate::UserActivateArgs, ban::UserBanArgs, check_access_pass::CheckUserAccessPassArgs, closeaccount::UserCloseAccountArgs, create::UserCreateArgs, @@ -224,6 +225,9 @@ pub enum DoubleZeroInstruction { CreateIndex(IndexCreateArgs), // variant 104 DeleteIndex(IndexDeleteArgs), // variant 105 SetUserBGPStatus(SetUserBGPStatusArgs), // variant 106 + CreateTopology(TopologyCreateArgs), // variant 107 + DeleteTopology(TopologyDeleteArgs), // variant 108 + ClearTopology(TopologyClearArgs), // variant 109 } impl DoubleZeroInstruction { @@ -355,10 +359,12 @@ impl DoubleZeroInstruction { 100 => Ok(Self::ResumePermission(PermissionResumeArgs::try_from(rest).unwrap())), 101 => Ok(Self::DeletePermission(PermissionDeleteArgs::try_from(rest).unwrap())), - 104 => Ok(Self::CreateIndex(IndexCreateArgs::try_from(rest).unwrap())), 105 => Ok(Self::DeleteIndex(IndexDeleteArgs::try_from(rest).unwrap())), 106 => Ok(Self::SetUserBGPStatus(SetUserBGPStatusArgs::try_from(rest).unwrap())), + 107 => Ok(Self::CreateTopology(TopologyCreateArgs::try_from(rest).unwrap())), + 108 => Ok(Self::DeleteTopology(TopologyDeleteArgs::try_from(rest).unwrap())), + 109 => Ok(Self::ClearTopology(TopologyClearArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -497,6 +503,9 @@ impl DoubleZeroInstruction { Self::CreateIndex(_) => "CreateIndex".to_string(), // variant 104 Self::DeleteIndex(_) => "DeleteIndex".to_string(), // variant 105 Self::SetUserBGPStatus(_) => "SetUserBGPStatus".to_string(), // variant 106 + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 } } @@ -627,6 +636,9 @@ impl DoubleZeroInstruction { Self::CreateIndex(args) => format!("{args:?}"), // variant 104 Self::DeleteIndex(args) => format!("{args:?}"), // variant 105 Self::SetUserBGPStatus(args) => format!("{args:?}"), // variant 106 + Self::CreateTopology(args) => format!("{args:?}"), // variant 107 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 + Self::ClearTopology(args) => format!("{args:?}"), // variant 109 } } } @@ -1322,5 +1334,24 @@ mod tests { }), "SetUserBGPStatus", ); + test_instruction( + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: crate::state::topology::TopologyConstraint::IncludeAny, + }), + "CreateTopology", + ); + test_instruction( + DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: "unicast-default".to_string(), + }), + "DeleteTopology", + ); + test_instruction( + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "unicast-default".to_string(), + }), + "ClearTopology", + ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs index 129390202c..4a4d4ec73b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs @@ -13,5 +13,6 @@ pub mod multicastgroup; pub mod permission; pub mod resource; pub mod tenant; +pub mod topology; pub mod user; pub mod validation; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs new file mode 100644 index 0000000000..12a533c238 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -0,0 +1,16 @@ +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyClearArgs { + pub name: String, +} + +pub fn process_topology_clear( + _program_id: &Pubkey, + _accounts: &[AccountInfo], + _value: &TopologyClearArgs, +) -> ProgramResult { + todo!("TopologyClear not yet implemented") +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs new file mode 100644 index 0000000000..48c7263cee --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -0,0 +1,199 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + processors::resource::allocate_id, + resource::ResourceType, + seeds::{SEED_PREFIX, SEED_TOPOLOGY}, + serializer::{try_acc_create, try_acc_write}, + state::{ + accounttype::AccountType, + device::Device, + globalstate::GlobalState, + interface::{Interface, LoopbackType}, + topology::{FlexAlgoNodeSegment, TopologyConstraint, TopologyInfo}, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; + +pub const MAX_TOPOLOGY_NAME_LEN: usize = 32; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyCreateArgs { + pub name: String, + pub constraint: TopologyConstraint, +} + +/// Accounts layout: +/// [0] topology PDA (writable, to be created) +/// [1] admin_group_bits (writable, ResourceExtension) +/// [2] globalstate (readonly) +/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [4] system_program +/// [5] segment_routing_ids (writable, ResourceExtension) — only if Vpnv4 loopbacks passed +/// [6+] Vpnv4 loopback Interface accounts (writable) — optional, for backfill +/// +/// If no Vpnv4 loopbacks are passed, account [5] can be omitted. +pub fn process_topology_create( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyCreateArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + let topology_account = next_account_info(accounts_iter)?; + let admin_group_bits_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer account must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(&globalstate_account.data.borrow()[..])?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyCreate: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate name length + if value.name.len() > MAX_TOPOLOGY_NAME_LEN { + msg!( + "TopologyCreate: name exceeds {} bytes", + MAX_TOPOLOGY_NAME_LEN + ); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + // Validate and verify topology PDA + let (expected_pda, bump_seed) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyCreate: invalid topology PDA for name '{}'", + value.name + ); + + if !topology_account.data_is_empty() { + msg!("TopologyCreate: topology '{}' already exists", value.name); + return Err(ProgramError::AccountAlreadyInitialized); + } + + // Validate AdminGroupBits resource account + assert_eq!( + admin_group_bits_account.owner, program_id, + "TopologyCreate: invalid AdminGroupBits account owner" + ); + + // Allocate admin_group_bit (lowest available; bit 1 is pre-marked = never returned) + let admin_group_bit_u16 = allocate_id(admin_group_bits_account)?; + if admin_group_bit_u16 > 127 { + msg!("TopologyCreate: AdminGroupBits exhausted (max 128 topologies)"); + return Err(DoubleZeroError::AllocationFailed.into()); + } + let admin_group_bit = admin_group_bit_u16 as u8; + let flex_algo_number = 128u8 + .checked_add(admin_group_bit) + .ok_or(DoubleZeroError::ArithmeticOverflow)?; + + // Create the topology PDA account + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: *payer_account.key, + bump_seed, + name: value.name.clone(), + admin_group_bit, + flex_algo_number, + constraint: value.constraint, + }; + + try_acc_create( + &topology, + topology_account, + payer_account, + system_program, + program_id, + &[ + SEED_PREFIX, + SEED_TOPOLOGY, + value.name.as_bytes(), + &[bump_seed], + ], + )?; + + // Backfill Vpnv4 loopbacks (remaining accounts after system_program) + // Convention: if any Device accounts are passed, segment_routing_ids must be + // the last account; Device accounts precede it. + let remaining: Vec<&AccountInfo> = accounts_iter.collect(); + if !remaining.is_empty() { + let (device_accounts, tail) = remaining.split_at(remaining.len() - 1); + let segment_routing_ids_account = tail[0]; + + // Validate the SegmentRoutingIds account + let (expected_sr_pda, _, _) = + crate::pda::get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + assert_eq!( + segment_routing_ids_account.key, &expected_sr_pda, + "TopologyCreate: invalid SegmentRoutingIds PDA" + ); + + for device_account in device_accounts { + if device_account.owner != program_id { + continue; + } + let mut device = Device::try_from(&device_account.data.borrow()[..])?; + let mut modified = false; + for iface in device.interfaces.iter_mut() { + let iface_v3 = iface.into_current_version(); + if iface_v3.loopback_type != LoopbackType::Vpnv4 { + continue; + } + // Skip if already has a segment for this topology (idempotent) + if iface_v3 + .flex_algo_node_segments + .iter() + .any(|s| &s.topology == topology_account.key) + { + continue; + } + let node_segment_idx = allocate_id(segment_routing_ids_account)?; + // Mutate the interface in place — we need to upgrade to V3 if needed + match iface { + Interface::V3(ref mut v3) => { + v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + } + _ => { + // Upgrade to V3 with the segment added + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + *iface = Interface::V3(upgraded); + } + } + modified = true; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + } + + msg!( + "TopologyCreate: created '{}' bit={} algo={} constraint={:?}", + value.name, + admin_group_bit, + flex_algo_number, + value.constraint + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs new file mode 100644 index 0000000000..e9e30cb711 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -0,0 +1,16 @@ +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyDeleteArgs { + pub name: String, +} + +pub fn process_topology_delete( + _program_id: &Pubkey, + _accounts: &[AccountInfo], + _value: &TopologyDeleteArgs, +) -> ProgramResult { + todo!("TopologyDelete not yet implemented") +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs new file mode 100644 index 0000000000..52a0eb0975 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs @@ -0,0 +1,3 @@ +pub mod clear; +pub mod create; +pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs index 50a88888de..58c6e4c544 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs @@ -197,6 +197,14 @@ impl AccountData { Err(DoubleZeroError::InvalidAccountType) } } + + pub fn get_topology(&self) -> Result { + if let AccountData::Topology(topology) = self { + Ok(topology.clone()) + } else { + Err(DoubleZeroError::InvalidAccountType) + } + } } impl TryFrom<&[u8]> for AccountData { diff --git a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs index 8e02ea58b7..4db532c685 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs @@ -1,10 +1,11 @@ -use crate::state::accounttype::AccountType; +use crate::{error::Validate, state::accounttype::AccountType}; use borsh::{BorshDeserialize, BorshSerialize}; use solana_program::pubkey::Pubkey; #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Clone, Copy, PartialEq, Default)] #[borsh(use_discriminant = true)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum TopologyConstraint { #[default] IncludeAny = 0, @@ -12,6 +13,7 @@ pub enum TopologyConstraint { } #[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct TopologyInfo { pub account_type: AccountType, pub owner: Pubkey, @@ -60,8 +62,21 @@ impl TryFrom<&solana_program::account_info::AccountInfo<'_>> for TopologyInfo { } } +impl Validate for TopologyInfo { + fn validate(&self) -> Result<(), crate::error::DoubleZeroError> { + if self.account_type != AccountType::Topology { + return Err(crate::error::DoubleZeroError::InvalidAccountType); + } + if self.name.len() > 32 { + return Err(crate::error::DoubleZeroError::NameTooLong); + } + Ok(()) + } +} + /// Flex-algo node segment entry on a Vpnv4 loopback Interface account. #[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct FlexAlgoNodeSegment { pub topology: Pubkey, // TopologyInfo PDA pubkey pub node_segment_idx: u16, // allocated from SegmentRoutingIds ResourceExtension diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 1b637510ba..42499190fe 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -2,16 +2,109 @@ use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::get_resource_extension_pda, - processors::resource::create::ResourceCreateArgs, + pda::{ + get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, + get_location_pda, get_resource_extension_pda, get_topology_pda, + }, + processors::{ + contributor::create::ContributorCreateArgs, + device::{ + activate::DeviceActivateArgs, create::DeviceCreateArgs, + interface::create::DeviceInterfaceCreateArgs, + }, + exchange::create::ExchangeCreateArgs, + location::create::LocationCreateArgs, + resource::create::ResourceCreateArgs, + topology::create::TopologyCreateArgs, + }, resource::{IdOrIp, ResourceType}, + state::{ + accounttype::AccountType, + device::{DeviceDesiredStatus, DeviceType}, + interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, + topology::{TopologyConstraint, TopologyInfo}, + }, }; +use solana_program::instruction::InstructionError; use solana_program_test::*; -use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; +use solana_sdk::{ + instruction::AccountMeta, pubkey::Pubkey, signature::Keypair, signer::Signer, + transaction::TransactionError, +}; mod test_helpers; use test_helpers::*; +/// Creates the AdminGroupBits resource extension. +/// Requires that global state + global config are already initialized. +async fn create_admin_group_bits( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + globalconfig_pubkey: Pubkey, + payer: &Keypair, +) -> Pubkey { + let (resource_pubkey, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(resource_pubkey, false), + AccountMeta::new(Pubkey::default(), false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + payer, + ) + .await; + resource_pubkey +} + +/// Helper that creates the topology using the standard account layout. +async fn create_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + admin_group_bits_pda: Pubkey, + name: &str, + constraint: TopologyConstraint, + payer: &Keypair, +) -> Pubkey { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: name.to_string(), + constraint, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; + topology_pda +} + +async fn get_topology(banks_client: &mut BanksClient, pubkey: Pubkey) -> TopologyInfo { + get_account_data(banks_client, pubkey) + .await + .expect("Topology account should exist") + .get_topology() + .expect("Account should be a Topology") +} + #[tokio::test] async fn test_admin_group_bits_create_and_pre_mark() { println!("[TEST] test_admin_group_bits_create_and_pre_mark"); @@ -104,9 +197,550 @@ fn test_flex_algo_node_segment_roundtrip() { assert_eq!(decoded.node_segment_idx, 1001); } -#[test] -fn test_interface_v3_defaults_flex_algo_node_segments_empty() { - use doublezero_serviceability::state::interface::InterfaceV3; - let iface = InterfaceV3::default(); - assert!(iface.flex_algo_node_segments.is_empty()); +// ============================================================================ +// Integration tests for TopologyCreate instruction +// ============================================================================ + +#[tokio::test] +async fn test_topology_create_bit_0_first() { + println!("[TEST] test_topology_create_bit_0_first"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology = get_topology(&mut banks_client, topology_pda).await; + + assert_eq!(topology.account_type, AccountType::Topology); + assert_eq!(topology.name, "unicast-default"); + assert_eq!(topology.admin_group_bit, 0); + assert_eq!(topology.flex_algo_number, 128); + assert_eq!(topology.constraint, TopologyConstraint::IncludeAny); + + println!("[PASS] test_topology_create_bit_0_first"); +} + +#[tokio::test] +async fn test_topology_create_second_skips_bit_1() { + println!("[TEST] test_topology_create_second_skips_bit_1"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // First topology gets bit 0 + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Second topology must skip bit 1 (pre-marked UNICAST-DRAINED) and get bit 2 + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "shelby", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology = get_topology(&mut banks_client, topology_pda).await; + + assert_eq!(topology.name, "shelby"); + assert_eq!( + topology.admin_group_bit, 2, + "bit 1 should be skipped (UNICAST-DRAINED)" + ); + assert_eq!(topology.flex_algo_number, 130); + + println!("[PASS] test_topology_create_second_skips_bit_1"); +} + +#[tokio::test] +async fn test_topology_create_non_foundation_rejected() { + println!("[TEST] test_topology_create_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let (topology_pda, _) = get_topology_pda(&program_id, "unauthorized-topology"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unauthorized-topology".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_create_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_create_name_too_long_rejected() { + println!("[TEST] test_topology_create_name_too_long_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // 33-char name exceeds MAX_TOPOLOGY_NAME_LEN=32 + // We use a dummy pubkey for the topology PDA since the name validation fires + // before the PDA check, and find_program_address panics on seeds > 32 bytes. + let long_name = "a".repeat(33); + let topology_pda = Pubkey::new_unique(); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: long_name, + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // DoubleZeroError::InvalidArgument = Custom(65) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(65), + ))) => {} + _ => panic!( + "Expected InvalidArgument error (Custom(65)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_create_name_too_long_rejected"); +} + +#[tokio::test] +async fn test_topology_create_duplicate_rejected() { + println!("[TEST] test_topology_create_duplicate_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // First creation succeeds + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Second creation of same name must fail. + // Wait for a new blockhash to avoid transaction deduplication in the test environment. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // ProgramError::AccountAlreadyInitialized maps to InstructionError::AccountAlreadyInitialized + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::AccountAlreadyInitialized, + ))) => {} + _ => panic!("Expected AccountAlreadyInitialized error, got {:?}", result), + } + + println!("[PASS] test_topology_create_duplicate_rejected"); +} + +#[tokio::test] +async fn test_topology_create_backfills_vpnv4_loopbacks() { + println!("[TEST] test_topology_create_backfills_vpnv4_loopbacks"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Create AdminGroupBits and SegmentRoutingIds resources + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Set up a full device with a Vpnv4 loopback interface + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface (without onchain allocation — backfill assigns the segment) + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "loopback0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Create topology passing the Device + SegmentRoutingIds as remaining accounts + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let instruction = DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }); + let base_accounts = vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(segment_routing_ids_pda, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &instruction, + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: the Vpnv4 loopback now has a flex_algo_node_segment pointing to the topology + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after backfill" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the newly created topology" + ); + + // Step 8: Call TopologyCreate again with same device — idempotent, no duplicate segment + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + // Create a second topology so we get a different PDA but still exercise idempotency + // by passing the device again — the first topology's segment must not be duplicated. + // Instead, verify idempotency by calling CreateTopology with the same device a second time + // using a different topology name, then checking the device has exactly two segments (not three). + let (topology2_pda, _) = get_topology_pda(&program_id, "unicast-secondary"); + let instruction2 = DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-secondary".to_string(), + constraint: TopologyConstraint::IncludeAny, + }); + let base_accounts2 = vec![ + AccountMeta::new(topology2_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let mut tx2 = create_transaction_with_extra_accounts( + program_id, + &instruction2, + &base_accounts2, + &payer, + &extra_accounts, + ); + tx2.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx2).await.unwrap(); + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after second topology"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 2, + "Expected two segments after second topology backfill (one per topology)" + ); + + // Step 9: Idempotency — call CreateTopology for unicast-secondary again with the same device. + // The segment for unicast-secondary must not be duplicated. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + // We need a new topology PDA since unicast-secondary already exists; + // instead use unicast-secondary's PDA but re-create a third topology and pass the device twice. + // Actually the simplest idempotency check: use a third unique topology but re-pass the device — + // after the call, the device should have exactly 3 segments (not more). + // The real idempotency guard is: if we pass a device that already has a segment for topology X, + // a second CreateTopology for X with that device does not add another. We test this by + // calling CreateTopology for topology2 again (which would fail because account already initialized), + // but instead we verify directly: re-run step 8 with the same topology2 already existing — + // the transaction should fail with AccountAlreadyInitialized before the backfill runs. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx_idem = create_transaction_with_extra_accounts( + program_id, + &instruction2, + &base_accounts2, + &payer, + &extra_accounts, + ); + tx_idem.try_sign(&[&payer], recent_blockhash).unwrap(); + let idem_result = banks_client.process_transaction(tx_idem).await; + match idem_result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::AccountAlreadyInitialized, + ))) => {} + _ => panic!( + "Expected AccountAlreadyInitialized on duplicate create, got {:?}", + idem_result + ), + } + + println!("[PASS] test_topology_create_backfills_vpnv4_loopbacks"); } From d527babb711839f2607e0db47cdef6717e01ff56 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 21:24:24 -0500 Subject: [PATCH 04/54] smartcontract: add TopologyDelete and TopologyClear instructions --- activator/src/process/link.rs | 10 + activator/src/processor.rs | 4 + .../fixtures/generate-fixtures/src/main.rs | 1 + smartcontract/cli/src/link/accept.rs | 2 + smartcontract/cli/src/link/delete.rs | 2 + smartcontract/cli/src/link/dzx_create.rs | 2 + smartcontract/cli/src/link/get.rs | 2 + smartcontract/cli/src/link/latency.rs | 1 + smartcontract/cli/src/link/list.rs | 18 + smartcontract/cli/src/link/sethealth.rs | 4 + smartcontract/cli/src/link/update.rs | 4 + smartcontract/cli/src/link/wan_create.rs | 2 + .../src/instructions.rs | 1 + .../src/processors/link/create.rs | 1 + .../src/processors/link/update.rs | 15 + .../src/processors/topology/clear.rs | 81 +- .../src/processors/topology/delete.rs | 78 +- .../src/state/link.rs | 18 +- .../tests/link_wan_test.rs | 1 + .../tests/topology_test.rs | 881 +++++++++++++++++- ...initialize_device_latency_samples_tests.rs | 1 + .../sdk/rs/src/commands/link/accept.rs | 4 + .../sdk/rs/src/commands/link/activate.rs | 4 + .../sdk/rs/src/commands/link/closeaccount.rs | 4 + .../sdk/rs/src/commands/link/delete.rs | 2 + .../sdk/rs/src/commands/link/update.rs | 1 + 26 files changed, 1127 insertions(+), 17 deletions(-) diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index fee82e46be..91344d130f 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -271,6 +271,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let tunnel_cloned = tunnel.clone(); @@ -397,6 +399,8 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link_cloned = link.clone(); @@ -457,6 +461,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let tunnel_clone = tunnel.clone(); @@ -544,6 +550,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // SDK command fetches the link internally @@ -623,6 +631,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // SDK command fetches the link internally diff --git a/activator/src/processor.rs b/activator/src/processor.rs index a43b736104..0b65aebd7a 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -764,6 +764,8 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let mut existing_links: HashMap = HashMap::new(); @@ -798,6 +800,8 @@ mod tests { side_z_iface_name: "Ethernet3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let new_link_cloned = new_link.clone(); diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 78b1293eda..e0e6587d08 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -433,6 +433,7 @@ fn generate_link(dir: &Path) { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let data = borsh::to_vec(&val).unwrap(); diff --git a/smartcontract/cli/src/link/accept.rs b/smartcontract/cli/src/link/accept.rs index 985f5421b8..eba0b9af0d 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -251,6 +251,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index b50280354c..d5196af993 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -151,6 +151,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index bb7d878449..79d6c8010b 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -363,6 +363,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index f23c4ef885..9b99691e00 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -158,6 +158,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let contributor = Contributor { diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index 8e4a479cc9..57f65465c0 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -190,6 +190,7 @@ mod tests { delay_override_ns: 0, link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: Vec::new(), } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 39eaf7d061..684b963b9b 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -377,6 +377,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -571,6 +573,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -595,6 +599,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -743,6 +749,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -768,6 +776,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -916,6 +926,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -941,6 +953,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { @@ -1056,6 +1070,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1081,6 +1097,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index 90b9a5ac6a..39b9b1e61a 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -105,6 +105,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2 = Link { @@ -129,6 +131,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index df36333011..d4a55d2b49 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -212,6 +212,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let link2 = Link { @@ -236,6 +238,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index 795ea121c5..8be3597daf 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -410,6 +410,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; client diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index d288ae8b69..7bbdf82998 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -838,6 +838,7 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }), "UpdateLink", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index dc32780a36..658d1d9084 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -227,6 +227,7 @@ pub fn process_create_link( // link_health: LinkHealth::Pending, link_health: LinkHealth::ReadyForService, // Force the link to be ready for service until the health oracle is implemented, desired_status: value.desired_status.unwrap_or(LinkDesiredStatus::Activated), + link_topologies: Vec::new(), }; link.check_status_transition(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 16fa4c677d..751a3130b0 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -43,6 +43,7 @@ pub struct LinkUpdateArgs { pub tunnel_net: Option, #[incremental(default = false)] pub use_onchain_allocation: bool, + pub link_topologies: Option>, } impl fmt::Debug for LinkUpdateArgs { @@ -87,6 +88,9 @@ impl fmt::Debug for LinkUpdateArgs { if self.use_onchain_allocation { parts.push("use_onchain_allocation: true".to_string()); } + if let Some(ref link_topologies) = self.link_topologies { + parts.push(format!("link_topologies: {:?}", link_topologies)); + } write!(f, "{}", parts.join(", ")) } } @@ -364,6 +368,15 @@ pub fn process_update_link( try_acc_write(&side_z_dev, device_z_account, payer_account, accounts)?; } + // link_topologies is foundation-only + if let Some(link_topologies) = &value.link_topologies { + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("link_topologies update requires foundation allowlist"); + return Err(DoubleZeroError::NotAllowed.into()); + } + link.link_topologies = link_topologies.clone(); + } + link.check_status_transition(); try_acc_write(&link, link_account, payer_account, accounts)?; @@ -419,6 +432,7 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); @@ -472,6 +486,7 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs index 12a533c238..f7c5757f55 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -1,16 +1,87 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + serializer::try_acc_write, + state::{globalstate::GlobalState, link::Link}, +}; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; -use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; #[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] pub struct TopologyClearArgs { pub name: String, } +/// Accounts layout: +/// [0] topology PDA (readonly, for key validation) +/// [1] globalstate (readonly) +/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [3+] Link accounts (writable) — remove topology pubkey from link_topologies on each pub fn process_topology_clear( - _program_id: &Pubkey, - _accounts: &[AccountInfo], - _value: &TopologyClearArgs, + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyClearArgs, ) -> ProgramResult { - todo!("TopologyClear not yet implemented") + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_clear(name={})", value.name); + + // Payer must be a signer + assert!(payer_account.is_signer, "Payer must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyClear: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyClear: invalid topology PDA for name '{}'", + value.name + ); + + // We don't require the topology to still exist (it may already be closed). + // The validation above confirms the key matches the expected PDA for the name. + + let topology_key = topology_account.key; + let mut cleared_count: usize = 0; + + // Process remaining Link accounts: remove topology key from link_topologies + for link_account in accounts_iter { + if link_account.data_is_empty() { + continue; + } + let mut link = match Link::try_from(link_account) { + Ok(l) => l, + Err(_) => continue, + }; + let before_len = link.link_topologies.len(); + link.link_topologies.retain(|k| k != topology_key); + if link.link_topologies.len() < before_len { + try_acc_write(&link, link_account, payer_account, accounts)?; + cleared_count += 1; + } + } + + msg!( + "TopologyClear: removed topology '{}' from {} link(s)", + value.name, + cleared_count + ); + Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs index e9e30cb711..d42a37c534 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -1,16 +1,84 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + serializer::try_acc_close, + state::{globalstate::GlobalState, link::Link, topology::TopologyInfo}, +}; use borsh::BorshSerialize; use borsh_incremental::BorshDeserializeIncremental; -use solana_program::{account_info::AccountInfo, entrypoint::ProgramResult, pubkey::Pubkey}; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; #[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] pub struct TopologyDeleteArgs { pub name: String, } +/// Accounts layout: +/// [0] topology PDA (writable, to be closed) +/// [1] globalstate (readonly) +/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [3+] Link accounts (readonly) — guard: fail if any references this topology pub fn process_topology_delete( - _program_id: &Pubkey, - _accounts: &[AccountInfo], - _value: &TopologyDeleteArgs, + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyDeleteArgs, ) -> ProgramResult { - todo!("TopologyDelete not yet implemented") + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_delete(name={})", value.name); + + // Payer must be a signer + assert!(payer_account.is_signer, "Payer must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyDelete: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyDelete: invalid topology PDA for name '{}'", + value.name + ); + + // Deserialize topology to get its pubkey for reference checks + let _topology = TopologyInfo::try_from(topology_account)?; + + // Check remaining Link accounts — fail if any reference this topology + for link_account in accounts_iter { + if link_account.data_is_empty() { + continue; + } + if let Ok(link) = Link::try_from(link_account) { + if link.link_topologies.contains(topology_account.key) { + msg!( + "TopologyDelete: link {} still references topology {}", + link_account.key, + topology_account.key + ); + return Err(DoubleZeroError::ReferenceCountNotZero.into()); + } + } + } + + // Close the topology PDA (transfer lamports to payer, zero data) + // NOTE: We do NOT deallocate the admin-group bit — bits are permanently marked. + try_acc_close(topology_account, payer_account)?; + + msg!("TopologyDelete: closed topology '{}'", value.name); + Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/link.rs b/smartcontract/programs/doublezero-serviceability/src/state/link.rs index 2a2025ddea..0bbaaa437b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/link.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/link.rs @@ -264,14 +264,15 @@ pub struct Link { pub delay_override_ns: u64, // 8 pub link_health: LinkHealth, // 1 pub desired_status: LinkDesiredStatus, // 1 + pub link_topologies: Vec, // 4 + 32 * len } impl fmt::Display for Link { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}", - self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status + "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}", + self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies ) } } @@ -300,6 +301,7 @@ impl Default for Link { delay_override_ns: 0, link_health: LinkHealth::Pending, desired_status: LinkDesiredStatus::Pending, + link_topologies: Vec::new(), } } } @@ -330,6 +332,7 @@ impl TryFrom<&[u8]> for Link { delay_override_ns: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), link_health: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), desired_status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + link_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Link { @@ -549,6 +552,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let data = borsh::to_vec(&val).unwrap(); @@ -602,6 +606,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); assert!(err.is_err()); @@ -632,6 +637,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); assert!(err.is_err()); @@ -662,6 +668,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; // For Rejected status, tunnel_net is not validated and should succeed @@ -692,6 +699,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); assert!(err.is_err()); @@ -722,6 +730,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -760,6 +769,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -799,6 +809,7 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err = val.validate(); @@ -830,6 +841,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -868,6 +880,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -906,6 +919,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; assert!(bad_link.validate().is_ok()); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index e3fee82601..2fa93ddb7d 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -606,6 +606,7 @@ async fn test_wan_link() { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, }), vec![ AccountMeta::new(tunnel_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 42499190fe..fd612c24f4 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -3,25 +3,33 @@ use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{ - get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, + get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, get_location_pda, get_resource_extension_pda, get_topology_pda, }, processors::{ contributor::create::ContributorCreateArgs, device::{ - activate::DeviceActivateArgs, create::DeviceCreateArgs, - interface::create::DeviceInterfaceCreateArgs, + activate::DeviceActivateArgs, + create::DeviceCreateArgs, + interface::{ + activate::DeviceInterfaceActivateArgs, create::DeviceInterfaceCreateArgs, + unlink::DeviceInterfaceUnlinkArgs, + }, }, exchange::create::ExchangeCreateArgs, + link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, resource::create::ResourceCreateArgs, - topology::create::TopologyCreateArgs, + topology::{ + clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, + }, }, resource::{IdOrIp, ResourceType}, state::{ accounttype::AccountType, device::{DeviceDesiredStatus, DeviceType}, interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, + link::{Link, LinkDesiredStatus, LinkLinkType}, topology::{TopologyConstraint, TopologyInfo}, }, }; @@ -711,7 +719,7 @@ async fn test_topology_create_backfills_vpnv4_loopbacks() { // Step 9: Idempotency — call CreateTopology for unicast-secondary again with the same device. // The segment for unicast-secondary must not be duplicated. - let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let _recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; // We need a new topology PDA since unicast-secondary already exists; // instead use unicast-secondary's PDA but re-create a third topology and pass the device twice. // Actually the simplest idempotency check: use a third unique topology but re-pass the device — @@ -744,3 +752,866 @@ async fn test_topology_create_backfills_vpnv4_loopbacks() { println!("[PASS] test_topology_create_backfills_vpnv4_loopbacks"); } + +// ============================================================================ +// Helpers for delete/clear tests +// ============================================================================ + +/// Creates a delete topology instruction. +async fn delete_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + extra_link_accounts: Vec, + payer: &Keypair, +) -> Result<(), BanksClientError> { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let base_accounts = vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts: Vec = extra_link_accounts; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: name.to_string(), + }), + &base_accounts, + payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await +} + +/// Creates a clear topology instruction, passing the given link accounts as writable. +async fn clear_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + link_accounts: Vec, + payer: &Keypair, +) { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: name.to_string(), + }), + &base_accounts, + payer, + &link_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); +} + +/// Gets a Link account (panics if not found or not deserializable). +async fn get_link(banks_client: &mut BanksClient, pubkey: Pubkey) -> Link { + let account = banks_client + .get_account(pubkey) + .await + .unwrap() + .expect("Link account should exist"); + Link::try_from(&account.data[..]).expect("Should deserialize as Link") +} + +/// Sets up a minimal WAN link (two devices, contributor, location, exchange, one link). +/// Returns (link_pubkey, contributor_pubkey, device_a_pubkey, device_z_pubkey). +#[allow(clippy::too_many_arguments)] +async fn setup_wan_link( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + payer: &Keypair, +) -> (Pubkey, Pubkey, Pubkey, Pubkey) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Location + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Exchange + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Contributor + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device A + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (device_a_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dza".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device A interface + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Ethernet0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + ip_net: None, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Ethernet0".to_string(), + ip_net: "10.0.0.0/31".parse().unwrap(), + node_segment_idx: 0, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device Z + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (device_z_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dzb".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [9, 9, 9, 9].into(), + dz_prefixes: "111.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device Z interface + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Ethernet1".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + ip_net: None, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Ethernet1".to_string(), + ip_net: "10.0.0.1/31".parse().unwrap(), + node_segment_idx: 0, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Unlink interfaces (make them available for linking) + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet0".to_string(), + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet1".to_string(), + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Create link + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (link_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLink(LinkCreateArgs { + code: "dza-dzb".to_string(), + link_type: LinkLinkType::WAN, + bandwidth: 20_000_000_000, + mtu: 9000, + delay_ns: 1_000_000, + jitter_ns: 100_000, + side_a_iface_name: "Ethernet0".to_string(), + side_z_iface_name: Some("Ethernet1".to_string()), + desired_status: Some(LinkDesiredStatus::Activated), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Activate link + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.100.0.0/30".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + ( + link_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + ) +} + +/// Assigns link_topologies on a link via LinkUpdate (foundation-only). +async fn assign_link_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + link_pubkey: Pubkey, + contributor_pubkey: Pubkey, + topology_pubkeys: Vec, + payer: &Keypair, +) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(topology_pubkeys), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; +} + +// ============================================================================ +// TopologyDelete tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_delete_succeeds_when_no_links() { + println!("[TEST] test_topology_delete_succeeds_when_no_links"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Verify it exists + let topology = get_topology(&mut banks_client, topology_pda).await; + assert_eq!(topology.name, "test-topology"); + + // Delete it with no link accounts + delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![], + &payer, + ) + .await + .expect("Delete should succeed with no referencing links"); + + // Verify account data is zeroed (closed) + let account = banks_client.get_account(topology_pda).await.unwrap(); + assert!( + account.is_none() || account.unwrap().data.is_empty(), + "Topology account should be closed after delete" + ); + + println!("[PASS] test_topology_delete_succeeds_when_no_links"); +} + +#[tokio::test] +async fn test_topology_delete_fails_when_link_references_it() { + println!("[TEST] test_topology_delete_fails_when_link_references_it"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link and assign the topology to it + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + // Verify the link references the topology + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.contains(&topology_pda)); + + // Attempt to delete — should fail because the link still references it + let result = delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new_readonly(link_pubkey, false)], + &payer, + ) + .await; + + // DoubleZeroError::ReferenceCountNotZero = Custom(13) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(13), + ))) => {} + _ => panic!( + "Expected ReferenceCountNotZero error (Custom(13)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_delete_fails_when_link_references_it"); +} + +#[tokio::test] +async fn test_topology_delete_bit_not_reused() { + println!("[TEST] test_topology_delete_bit_not_reused"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Create "topology-a" — gets bit 0 + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "topology-a", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Delete "topology-a" + delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topology-a", + vec![], + &payer, + ) + .await + .expect("Delete should succeed"); + + // Create "topology-b" — must NOT get bit 0 (permanently marked) or bit 1 (UNICAST-DRAINED) + // so it should get bit 2 + let topology_b_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "topology-b", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology_b = get_topology(&mut banks_client, topology_b_pda).await; + assert_eq!( + topology_b.admin_group_bit, 2, + "topology-b should get bit 2 (bit 0 permanently marked even after delete, bit 1 is UNICAST-DRAINED)" + ); + + println!("[PASS] test_topology_delete_bit_not_reused"); +} + +// ============================================================================ +// TopologyClear tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_clear_removes_from_links() { + println!("[TEST] test_topology_clear_removes_from_links"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link and assign the topology to it + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + // Verify assignment + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.contains(&topology_pda)); + + // Clear topology from the link + clear_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new(link_pubkey, false)], + &payer, + ) + .await; + + // Verify the link no longer references the topology + let link = get_link(&mut banks_client, link_pubkey).await; + assert!( + !link.link_topologies.contains(&topology_pda), + "link_topologies should be empty after clear" + ); + assert!(link.link_topologies.is_empty()); + + println!("[PASS] test_topology_clear_removes_from_links"); +} + +#[tokio::test] +async fn test_topology_clear_is_idempotent() { + println!("[TEST] test_topology_clear_is_idempotent"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link but do NOT assign the topology + let (link_pubkey, _, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Verify link has no topology assignment + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.is_empty()); + + // Call clear — link does not reference topology, so nothing should change, no error + clear_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new(link_pubkey, false)], + &payer, + ) + .await; + + // Verify link is still empty + let link = get_link(&mut banks_client, link_pubkey).await; + assert!( + link.link_topologies.is_empty(), + "link_topologies should still be empty" + ); + + println!("[PASS] test_topology_clear_is_idempotent"); +} + +#[tokio::test] +async fn test_topology_delete_non_foundation_rejected() { + println!("[TEST] test_topology_delete_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Create topology with foundation payer + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let result = delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "unicast-default", + vec![], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_delete_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_clear_non_foundation_rejected() { + println!("[TEST] test_topology_clear_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Create topology with foundation payer + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + // Attempt ClearTopology with non-foundation payer + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "unicast-default".to_string(), + }), + vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_clear_non_foundation_rejected"); +} diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index 66df5fe93f..30efbc4dee 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -710,6 +710,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { side_z_iface_name: "Ethernet1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: Vec::new(), }; let mut data = Vec::new(); diff --git a/smartcontract/sdk/rs/src/commands/link/accept.rs b/smartcontract/sdk/rs/src/commands/link/accept.rs index ec736b3aa7..7486d3bf67 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -134,6 +134,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let device_z = doublezero_serviceability::state::device::Device { @@ -235,6 +237,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; let device_z = doublezero_serviceability::state::device::Device { diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index b3e439bb27..93d370c8ef 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -126,6 +126,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Mock Link fetch @@ -195,6 +197,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs index bd5cd13b74..df86654759 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -115,6 +115,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Mock Link fetch @@ -185,6 +187,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/delete.rs b/smartcontract/sdk/rs/src/commands/link/delete.rs index 181fdd478a..e5bffb05fa 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -106,6 +106,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Activated, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), } } diff --git a/smartcontract/sdk/rs/src/commands/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 3df2feff7b..41c8009196 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -100,6 +100,7 @@ impl UpdateLinkCommand { tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, use_onchain_allocation, + link_topologies: None, }), accounts, ) From 476b7bed9b50b0d3ab7a1ed2bac7b7022945d7fe Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 22:59:13 -0500 Subject: [PATCH 05/54] smartcontract: add unicast_drained field to Link; contributor-writable --- activator/src/process/link.rs | 5 + activator/src/processor.rs | 2 + .../fixtures/generate-fixtures/src/main.rs | 1 + smartcontract/cli/src/link/accept.rs | 1 + smartcontract/cli/src/link/delete.rs | 1 + smartcontract/cli/src/link/dzx_create.rs | 1 + smartcontract/cli/src/link/get.rs | 1 + smartcontract/cli/src/link/latency.rs | 1 + smartcontract/cli/src/link/list.rs | 9 + smartcontract/cli/src/link/sethealth.rs | 2 + smartcontract/cli/src/link/update.rs | 2 + smartcontract/cli/src/link/wan_create.rs | 1 + .../src/instructions.rs | 1 + .../src/processors/link/create.rs | 1 + .../src/processors/link/update.rs | 18 ++ .../src/state/link.rs | 18 +- .../tests/link_wan_test.rs | 1 + .../tests/topology_test.rs | 225 ++++++++++++++++++ ...initialize_device_latency_samples_tests.rs | 1 + .../sdk/rs/src/commands/link/accept.rs | 2 + .../sdk/rs/src/commands/link/activate.rs | 2 + .../sdk/rs/src/commands/link/closeaccount.rs | 2 + .../sdk/rs/src/commands/link/delete.rs | 1 + .../sdk/rs/src/commands/link/update.rs | 1 + 24 files changed, 298 insertions(+), 2 deletions(-) diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index 91344d130f..347fe13e2e 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -273,6 +273,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let tunnel_cloned = tunnel.clone(); @@ -401,6 +402,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link_cloned = link.clone(); @@ -463,6 +465,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let tunnel_clone = tunnel.clone(); @@ -552,6 +555,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // SDK command fetches the link internally @@ -633,6 +637,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // SDK command fetches the link internally diff --git a/activator/src/processor.rs b/activator/src/processor.rs index 0b65aebd7a..ba6fee1ed5 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -766,6 +766,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let mut existing_links: HashMap = HashMap::new(); @@ -802,6 +803,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let new_link_cloned = new_link.clone(); diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index e0e6587d08..9d2f49d502 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -434,6 +434,7 @@ fn generate_link(dir: &Path) { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let data = borsh::to_vec(&val).unwrap(); diff --git a/smartcontract/cli/src/link/accept.rs b/smartcontract/cli/src/link/accept.rs index eba0b9af0d..d5515de747 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -253,6 +253,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index d5196af993..1bc7a1c288 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -153,6 +153,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index 79d6c8010b..9c22168ce3 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -365,6 +365,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 9b99691e00..1f4eb4f067 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -160,6 +160,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let contributor = Contributor { diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index 57f65465c0..bb5c936b81 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -191,6 +191,7 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 684b963b9b..52ebeb472d 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -379,6 +379,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -575,6 +576,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -601,6 +603,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -751,6 +754,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -778,6 +782,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -928,6 +933,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -955,6 +961,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { @@ -1072,6 +1079,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1099,6 +1107,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index 39b9b1e61a..24611fd3d1 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -107,6 +107,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2 = Link { @@ -133,6 +134,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index d4a55d2b49..0c4f78c515 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -214,6 +214,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let link2 = Link { @@ -240,6 +241,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index 8be3597daf..e7e44cc74f 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -412,6 +412,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; client diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 7bbdf82998..d495056b8a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -839,6 +839,7 @@ mod tests { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }), "UpdateLink", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index 658d1d9084..fe7f440285 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -228,6 +228,7 @@ pub fn process_create_link( link_health: LinkHealth::ReadyForService, // Force the link to be ready for service until the health oracle is implemented, desired_status: value.desired_status.unwrap_or(LinkDesiredStatus::Activated), link_topologies: Vec::new(), + unicast_drained: false, }; link.check_status_transition(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 751a3130b0..90f361b78f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -44,6 +44,8 @@ pub struct LinkUpdateArgs { #[incremental(default = false)] pub use_onchain_allocation: bool, pub link_topologies: Option>, + #[incremental(default = None)] + pub unicast_drained: Option, } impl fmt::Debug for LinkUpdateArgs { @@ -91,6 +93,9 @@ impl fmt::Debug for LinkUpdateArgs { if let Some(ref link_topologies) = self.link_topologies { parts.push(format!("link_topologies: {:?}", link_topologies)); } + if let Some(unicast_drained) = self.unicast_drained { + parts.push(format!("unicast_drained: {:?}", unicast_drained)); + } write!(f, "{}", parts.join(", ")) } } @@ -377,6 +382,17 @@ pub fn process_update_link( link.link_topologies = link_topologies.clone(); } + // unicast_drained: contributor A or foundation + if let Some(unicast_drained) = value.unicast_drained { + if link.contributor_pk != *contributor_account.key + && !globalstate.foundation_allowlist.contains(payer_account.key) + { + msg!("unicast_drained update requires contributor A or foundation allowlist"); + return Err(DoubleZeroError::NotAllowed.into()); + } + link.unicast_drained = unicast_drained; + } + link.check_status_transition(); try_acc_write(&link, link_account, payer_account, accounts)?; @@ -433,6 +449,7 @@ mod tests { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); @@ -487,6 +504,7 @@ mod tests { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/link.rs b/smartcontract/programs/doublezero-serviceability/src/state/link.rs index 0bbaaa437b..95d726e48a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/link.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/link.rs @@ -265,14 +265,15 @@ pub struct Link { pub link_health: LinkHealth, // 1 pub desired_status: LinkDesiredStatus, // 1 pub link_topologies: Vec, // 4 + 32 * len + pub unicast_drained: bool, // 1 } impl fmt::Display for Link { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}", - self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies + "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}, unicast_drained: {}", + self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies, self.unicast_drained ) } } @@ -302,6 +303,7 @@ impl Default for Link { link_health: LinkHealth::Pending, desired_status: LinkDesiredStatus::Pending, link_topologies: Vec::new(), + unicast_drained: false, } } } @@ -333,6 +335,7 @@ impl TryFrom<&[u8]> for Link { link_health: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), desired_status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), link_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + unicast_drained: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Link { @@ -553,6 +556,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let data = borsh::to_vec(&val).unwrap(); @@ -607,6 +611,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); assert!(err.is_err()); @@ -638,6 +643,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); assert!(err.is_err()); @@ -669,6 +675,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // For Rejected status, tunnel_net is not validated and should succeed @@ -700,6 +707,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); assert!(err.is_err()); @@ -731,6 +739,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -770,6 +779,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -810,6 +820,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err = val.validate(); @@ -842,6 +853,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -881,6 +893,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -920,6 +933,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; assert!(bad_link.validate().is_ok()); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 2fa93ddb7d..1f22e908f5 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -607,6 +607,7 @@ async fn test_wan_link() { tunnel_net: None, use_onchain_allocation: false, link_topologies: None, + unicast_drained: None, }), vec![ AccountMeta::new(tunnel_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index fd612c24f4..e62d1380cb 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1615,3 +1615,228 @@ async fn test_topology_clear_non_foundation_rejected() { println!("[PASS] test_topology_clear_non_foundation_rejected"); } + +// ============================================================================ +// unicast_drained tests +// ============================================================================ + +#[tokio::test] +async fn test_link_unicast_drained_contributor_can_set_own_link() { + println!("[TEST] test_link_unicast_drained_contributor_can_set_own_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + setup_program_with_globalconfig().await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Verify unicast_drained starts as false + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(!link.unicast_drained); + + // Contributor A (payer) sets unicast_drained = true + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Read back: unicast_drained must be true + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.unicast_drained); + + println!("[PASS] test_link_unicast_drained_contributor_can_set_own_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_contributor_cannot_set_other_link() { + println!("[TEST] test_link_unicast_drained_contributor_cannot_set_other_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + setup_program_with_globalconfig().await; + + // Create the link owned by payer (contributor A) + let (link_pubkey, _contributor_a_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Create a second contributor owned by a different keypair (bad_actor) + let bad_actor = Keypair::new(); + transfer(&mut banks_client, &payer, &bad_actor.pubkey(), 10_000_000).await; + + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_b_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + // Foundation (payer) creates contributor B, owned by bad_actor + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "bad".to_string(), + }), + vec![ + AccountMeta::new(contributor_b_pubkey, false), + AccountMeta::new(bad_actor.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // bad_actor tries to set unicast_drained on contributor A's link using contributor B + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_b_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &bad_actor, + ) + .await; + + // DoubleZeroError::NotAllowed = Custom(8) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), + } + + println!("[PASS] test_link_unicast_drained_contributor_cannot_set_other_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_foundation_can_set_any_link() { + println!("[TEST] test_link_unicast_drained_foundation_can_set_any_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + setup_program_with_globalconfig().await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // payer is in the foundation allowlist; it sets unicast_drained on a contributor's link + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.unicast_drained); + + println!("[PASS] test_link_unicast_drained_foundation_can_set_any_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { + println!("[TEST] test_link_unicast_drained_orthogonal_to_status_and_topologies"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let admin_group_bits_pda = create_admin_group_bits( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Assign a topology to the link (foundation-only) + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + let link_before = get_link(&mut banks_client, link_pubkey).await; + assert!(link_before.link_topologies.contains(&topology_pda)); + assert!(!link_before.unicast_drained); + + // Set unicast_drained = true + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link_after = get_link(&mut banks_client, link_pubkey).await; + assert!(link_after.unicast_drained, "unicast_drained should be true"); + assert_eq!( + link_after.status, link_before.status, + "status should be unchanged" + ); + assert_eq!( + link_after.link_topologies, link_before.link_topologies, + "link_topologies should be unchanged" + ); + + println!("[PASS] test_link_unicast_drained_orthogonal_to_status_and_topologies"); +} diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index 30efbc4dee..bb1fa261a9 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -711,6 +711,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { link_health: LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let mut data = Vec::new(); diff --git a/smartcontract/sdk/rs/src/commands/link/accept.rs b/smartcontract/sdk/rs/src/commands/link/accept.rs index 7486d3bf67..565be69caf 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -136,6 +136,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let device_z = doublezero_serviceability::state::device::Device { @@ -239,6 +240,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; let device_z = doublezero_serviceability::state::device::Device { diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index 93d370c8ef..628a5574ed 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -128,6 +128,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Mock Link fetch @@ -199,6 +200,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs index df86654759..4a90b74dcc 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -117,6 +117,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Mock Link fetch @@ -189,6 +190,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/delete.rs b/smartcontract/sdk/rs/src/commands/link/delete.rs index e5bffb05fa..98c0ef2cbb 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -108,6 +108,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), + unicast_drained: false, } } diff --git a/smartcontract/sdk/rs/src/commands/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 41c8009196..192f6ee516 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -101,6 +101,7 @@ impl UpdateLinkCommand { tunnel_net: self.tunnel_net, use_onchain_allocation, link_topologies: None, + unicast_drained: None, }), accounts, ) From d8526755adb4383ba24eaa4fb680abaf8b000e98 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 23:22:35 -0500 Subject: [PATCH 06/54] smartcontract: add include_topologies to Tenant account; foundation-only --- smartcontract/cli/src/accesspass/get.rs | 1 + .../cli/src/tenant/add_administrator.rs | 1 + smartcontract/cli/src/tenant/create.rs | 1 + smartcontract/cli/src/tenant/delete.rs | 3 + smartcontract/cli/src/tenant/get.rs | 1 + smartcontract/cli/src/tenant/list.rs | 1 + .../cli/src/tenant/remove_administrator.rs | 1 + smartcontract/cli/src/tenant/update.rs | 1 + .../cli/src/tenant/update_payment_status.rs | 1 + smartcontract/cli/src/user/get.rs | 1 + smartcontract/cli/src/user/list.rs | 4 + .../src/instructions.rs | 1 + .../src/processors/tenant/create.rs | 1 + .../src/processors/tenant/update.rs | 17 +- .../src/state/tenant.rs | 16 +- .../tests/tenant_test.rs | 283 +++++++++++++++++- .../sdk/rs/src/commands/tenant/delete.rs | 1 + .../sdk/rs/src/commands/tenant/update.rs | 1 + 18 files changed, 328 insertions(+), 8 deletions(-) diff --git a/smartcontract/cli/src/accesspass/get.rs b/smartcontract/cli/src/accesspass/get.rs index 6a3607bf6e..84d9eebfea 100644 --- a/smartcontract/cli/src/accesspass/get.rs +++ b/smartcontract/cli/src/accesspass/get.rs @@ -161,6 +161,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mgroup_pubkey = Pubkey::new_unique(); diff --git a/smartcontract/cli/src/tenant/add_administrator.rs b/smartcontract/cli/src/tenant/add_administrator.rs index 290c700a23..00065095fb 100644 --- a/smartcontract/cli/src/tenant/add_administrator.rs +++ b/smartcontract/cli/src/tenant/add_administrator.rs @@ -83,6 +83,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/create.rs b/smartcontract/cli/src/tenant/create.rs index 9e73f76c00..36cd3aa167 100644 --- a/smartcontract/cli/src/tenant/create.rs +++ b/smartcontract/cli/src/tenant/create.rs @@ -109,6 +109,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/delete.rs b/smartcontract/cli/src/tenant/delete.rs index 0c6ba955da..7013a6e1c9 100644 --- a/smartcontract/cli/src/tenant/delete.rs +++ b/smartcontract/cli/src/tenant/delete.rs @@ -252,6 +252,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client @@ -311,6 +312,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client @@ -360,6 +362,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user = User { diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 323011233c..0b1e82acb7 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -103,6 +103,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant_cloned = tenant.clone(); diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index acc2b72e79..20d06a8e46 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -91,6 +91,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/remove_administrator.rs b/smartcontract/cli/src/tenant/remove_administrator.rs index 5e9deebbb9..db27bd8483 100644 --- a/smartcontract/cli/src/tenant/remove_administrator.rs +++ b/smartcontract/cli/src/tenant/remove_administrator.rs @@ -83,6 +83,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index a304eb559c..fbd8cfc83b 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -111,6 +111,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/update_payment_status.rs b/smartcontract/cli/src/tenant/update_payment_status.rs index b5843b093e..2d579c4bd8 100644 --- a/smartcontract/cli/src/tenant/update_payment_status.rs +++ b/smartcontract/cli/src/tenant/update_payment_status.rs @@ -84,6 +84,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index 790a266d73..a8680c4f95 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -180,6 +180,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let device_pubkey = Pubkey::new_unique(); diff --git a/smartcontract/cli/src/user/list.rs b/smartcontract/cli/src/user/list.rs index af5e4ddede..731197be35 100644 --- a/smartcontract/cli/src/user/list.rs +++ b/smartcontract/cli/src/user/list.rs @@ -1463,6 +1463,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1478,6 +1479,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); @@ -1638,6 +1640,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1653,6 +1656,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index d495056b8a..d33d5aad44 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -1290,6 +1290,7 @@ mod tests { metro_routing: Some(true), route_liveness: Some(false), billing: None, + include_topologies: None, }), "UpdateTenant", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs index 45cb623c6d..8475e08d76 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs @@ -139,6 +139,7 @@ pub fn process_create_tenant( metro_routing: value.metro_routing, route_liveness: value.route_liveness, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let deposit = Rent::get() diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs index 138cf15d07..877b4e0006 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs @@ -11,13 +11,11 @@ use borsh_incremental::BorshDeserializeIncremental; use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, + msg, pubkey::Pubkey, }; use std::fmt; -#[cfg(test)] -use solana_program::msg; - #[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] pub struct TenantUpdateArgs { pub vrf_id: Option, @@ -25,14 +23,16 @@ pub struct TenantUpdateArgs { pub metro_routing: Option, pub route_liveness: Option, pub billing: Option, + #[incremental(default = None)] + pub include_topologies: Option>, } impl fmt::Debug for TenantUpdateArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "vrf_id: {:?}, token_account: {:?}, metro_routing: {:?}, route_liveness: {:?}, billing: {:?}", - self.vrf_id, self.token_account, self.metro_routing, self.route_liveness, self.billing + "vrf_id: {:?}, token_account: {:?}, metro_routing: {:?}, route_liveness: {:?}, billing: {:?}, include_topologies: {:?}", + self.vrf_id, self.token_account, self.metro_routing, self.route_liveness, self.billing, self.include_topologies ) } } @@ -94,6 +94,13 @@ pub fn process_update_tenant( if let Some(billing) = value.billing { tenant.billing = billing; } + if let Some(ref topologies) = value.include_topologies { + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TenantUpdate: include_topologies requires foundation key"); + return Err(DoubleZeroError::NotAllowed.into()); + } + tenant.include_topologies = topologies.clone(); + } try_acc_write(&tenant, tenant_account, payer_account, accounts)?; Ok(()) diff --git a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs index 588ac6d847..1e58a1f4b2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs @@ -110,14 +110,22 @@ pub struct Tenant { pub metro_routing: bool, // 1 byte - enables tenant to be routed through metro for VRF requests pub route_liveness: bool, // 1 byte - enables tenant to be check for aliveness before routing pub billing: TenantBillingConfig, // 17 bytes (1 discriminant + 8 rate + 8 last_deduction_dz_epoch) + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkeylist_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkeylist_from_string" + ) + )] + pub include_topologies: Vec, // 4 + (32 * len) — foundation-only: flex-algo topologies for unicast VPN route steering } impl fmt::Display for Tenant { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, bump_seed: {}, code: {}, vrf_id: {}, administrators: {:?}, payment_status: {}, token_account: {}, metro_routing: {}, route_liveness: {}, billing: {}", - self.account_type, self.owner, self.bump_seed, self.code, self.vrf_id, self.administrators, self.payment_status, self.token_account, self.metro_routing, self.route_liveness, self.billing + "account_type: {}, owner: {}, bump_seed: {}, code: {}, vrf_id: {}, administrators: {:?}, payment_status: {}, token_account: {}, metro_routing: {}, route_liveness: {}, billing: {}, include_topologies: {:?}", + self.account_type, self.owner, self.bump_seed, self.code, self.vrf_id, self.administrators, self.payment_status, self.token_account, self.metro_routing, self.route_liveness, self.billing, self.include_topologies ) } } @@ -139,6 +147,7 @@ impl TryFrom<&[u8]> for Tenant { metro_routing: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), route_liveness: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), billing: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + include_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Tenant { @@ -213,6 +222,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let data = borsh::to_vec(&val).unwrap(); @@ -255,6 +265,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let err = val.validate(); assert!(err.is_err()); @@ -276,6 +287,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let err = val.validate(); assert!(err.is_err()); diff --git a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs index c738acc935..425c3fc9f7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs @@ -9,8 +9,14 @@ use doublezero_serviceability::{ resource::ResourceType, state::{accounttype::AccountType, tenant::*}, }; +use solana_program::instruction::InstructionError; use solana_program_test::*; -use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; +use solana_sdk::{ + instruction::AccountMeta, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::TransactionError, +}; mod test_helpers; use test_helpers::*; @@ -84,6 +90,7 @@ async fn test_tenant() { metro_routing: Some(false), route_liveness: Some(true), billing: None, + include_topologies: None, }), vec![ AccountMeta::new(tenant_pubkey, false), @@ -125,6 +132,7 @@ async fn test_tenant() { metro_routing: None, route_liveness: None, billing: Some(billing_config), + include_topologies: None, }), vec![ AccountMeta::new(tenant_pubkey, false), @@ -563,3 +571,276 @@ async fn test_tenant_remove_nonexistent_administrator_fails() { println!("✅ Nonexistent administrator removal correctly rejected"); println!("🟢🟢🟢 End test_tenant_remove_nonexistent_administrator_fails 🟢🟢🟢"); } + +#[tokio::test] +async fn test_tenant_include_topologies_defaults_to_empty() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-default"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + + assert_eq!(tenant.include_topologies, Vec::::new()); + + println!("✅ include_topologies defaults to empty on new Tenant"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_foundation_can_set() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-foundation"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // Foundation key (payer) sets include_topologies + let topology_pubkey = Pubkey::new_unique(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![topology_pubkey]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + + assert_eq!(tenant.include_topologies, vec![topology_pubkey]); + + println!("✅ Foundation key can set include_topologies"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_non_foundation_rejected() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-nonfoundation"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // A keypair not in the foundation allowlist + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![Pubkey::new_unique()]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::NotAllowed = Custom(8) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), + } + + println!("✅ Non-foundation key correctly rejected for include_topologies"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_reset_to_empty() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-reset"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // Set include_topologies to a non-empty list + let topology_pubkey = Pubkey::new_unique(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![topology_pubkey]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + assert_eq!(tenant.include_topologies, vec![topology_pubkey]); + + // Now reset to empty + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + assert_eq!(tenant.include_topologies, Vec::::new()); + + println!("✅ include_topologies can be reset to empty"); +} diff --git a/smartcontract/sdk/rs/src/commands/tenant/delete.rs b/smartcontract/sdk/rs/src/commands/tenant/delete.rs index f4580b0ae1..eed416badb 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/delete.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/delete.rs @@ -250,6 +250,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mut seq = Sequence::new(); diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index cd175fed5c..b0b511e37d 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -26,6 +26,7 @@ impl UpdateTenantCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing: self.billing, + include_topologies: None, }), vec![ AccountMeta::new(self.tenant_pubkey, false), From 495c7d703434a4464e65425593661e5efb4dbcde Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 31 Mar 2026 23:36:37 -0500 Subject: [PATCH 07/54] smartcontract: fix include_topologies test assert and fixture generator --- .../testdata/fixtures/generate-fixtures/src/main.rs | 1 + .../programs/doublezero-serviceability/src/state/tenant.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 9d2f49d502..2dac3cece6 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -761,6 +761,7 @@ fn generate_tenant(dir: &Path) { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let data = borsh::to_vec(&val).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs index 1e58a1f4b2..7923e22c39 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs @@ -205,6 +205,7 @@ mod tests { assert_eq!(val.administrators, Vec::::new()); assert_eq!(val.payment_status, TenantPaymentStatus::Delinquent); assert_eq!(val.token_account, Pubkey::default()); + assert_eq!(val.include_topologies, Vec::::new()); } #[test] From d51866aab1e469c1d768e1db628db652e3af4957 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 00:13:07 -0500 Subject: [PATCH 08/54] smartcontract: auto-tag UNICAST-DEFAULT topology at link activation --- .../src/processors/link/activate.rs | 18 +- .../tests/global_test.rs | 10 + .../tests/link_dzx_test.rs | 11 + .../tests/link_onchain_allocation_test.rs | 35 ++- .../tests/link_wan_test.rs | 200 ++++++++++++++++++ .../tests/test_helpers.rs | 63 +++++- .../tests/topology_test.rs | 116 ++++++++-- .../tests/unlink_device_interface_test.rs | 10 + 8 files changed, 441 insertions(+), 22 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index d43b4b3571..a597342f35 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -1,6 +1,6 @@ use crate::{ error::DoubleZeroError, - pda::get_resource_extension_pda, + pda::{get_resource_extension_pda, get_topology_pda}, processors::{ resource::{allocate_id, allocate_ip}, validation::validate_program_account, @@ -59,11 +59,11 @@ pub fn process_activate_link( let side_z_device_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; - // Optional: ResourceExtension accounts for on-chain allocation (before payer) + // Optional: ResourceExtension accounts for on-chain allocation (before unicast-default topology) // Account layout WITH ResourceExtension (use_onchain_allocation = true): - // [link, side_a_dev, side_z_dev, globalstate, device_tunnel_block, link_ids, payer, system] + // [link, side_a_dev, side_z_dev, globalstate, device_tunnel_block, link_ids, unicast_default, payer, system] // Account layout WITHOUT (legacy, use_onchain_allocation = false): - // [link, side_a_dev, side_z_dev, globalstate, payer, system] + // [link, side_a_dev, side_z_dev, globalstate, unicast_default, payer, system] let resource_extension_accounts = if value.use_onchain_allocation { let device_tunnel_block_ext = next_account_info(accounts_iter)?; // DeviceTunnelBlock (global) let link_ids_ext = next_account_info(accounts_iter)?; // LinkIds (global) @@ -72,6 +72,7 @@ pub fn process_activate_link( None }; + let unicast_default_topology_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -231,6 +232,15 @@ pub fn process_activate_link( link.check_status_transition(); + // Auto-tag with UNICAST-DEFAULT topology at activation + let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); + if unicast_default_topology_account.key != &expected_unicast_default_pda + || unicast_default_topology_account.data_is_empty() + { + return Err(DoubleZeroError::InvalidArgument.into()); + } + link.link_topologies = vec![*unicast_default_topology_account.key]; + try_acc_write(&side_a_dev, side_a_device_account, payer_account, accounts)?; try_acc_write(&side_z_dev, side_z_device_account, payer_account, accounts)?; try_acc_write(&link, link_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index 45516901d2..37bc558a36 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -693,6 +693,15 @@ async fn test_doublezero_program() { use_onchain_allocation: false, }; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -703,6 +712,7 @@ async fn test_doublezero_program() { AccountMeta::new(device_la_pubkey, false), AccountMeta::new(device_ny_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index 385e175443..b19b0befcf 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -614,6 +614,15 @@ async fn test_dzx_link() { /*****************************************************************************************************************************************************/ println!("🟢 13. Activate Link..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + // Regression: activation must fail if side A/Z accounts do not match link.side_{a,z}_pk let res = try_execute_transaction( &mut banks_client, @@ -630,6 +639,7 @@ async fn test_dzx_link() { AccountMeta::new(device_z_pubkey, false), AccountMeta::new(device_a_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -652,6 +662,7 @@ async fn test_dzx_link() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs index 9ddc1709a4..37682b28fd 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs @@ -38,7 +38,7 @@ use test_helpers::*; /// Test that ActivateLink works with onchain allocation from ResourceExtension #[tokio::test] async fn test_activate_link_with_onchain_allocation() { - let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -382,6 +382,15 @@ async fn test_activate_link_with_onchain_allocation() { // Activate Link with onchain allocation println!("Activating Link with onchain allocation..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -398,6 +407,7 @@ async fn test_activate_link_with_onchain_allocation() { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_pda, false), AccountMeta::new(link_ids_pda, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -441,7 +451,7 @@ async fn test_activate_link_with_onchain_allocation() { /// Test that the legacy ActivateLink path (without onchain allocation) still works #[tokio::test] async fn test_activate_link_legacy_path() { - let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -725,6 +735,15 @@ async fn test_activate_link_legacy_path() { let expected_tunnel_net: doublezero_program_common::types::NetworkV4 = "10.0.0.0/21".parse().unwrap(); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -739,6 +758,7 @@ async fn test_activate_link_legacy_path() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1036,6 +1056,16 @@ async fn test_closeaccount_link_with_deallocation() { get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); let (link_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::LinkIds); + // Create unicast-default topology (required for link activation) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate Link with onchain allocation execute_transaction( &mut banks_client, @@ -1053,6 +1083,7 @@ async fn test_closeaccount_link_with_deallocation() { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_pda, false), AccountMeta::new(link_ids_pda, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 1f22e908f5..c24b0ccac8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -5,6 +5,8 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::interface::{update::DeviceInterfaceUpdateArgs, DeviceInterfaceUnlinkArgs}, link::{activate::*, create::*, delete::*, sethealth::LinkSetHealthArgs, update::*}, + resource::create::ResourceCreateArgs, + topology::create::TopologyCreateArgs, *, }, resource::ResourceType, @@ -16,6 +18,7 @@ use doublezero_serviceability::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, link::*, + topology::TopologyConstraint, }, }; use globalconfig::set::SetGlobalConfigArgs; @@ -556,6 +559,15 @@ async fn test_wan_link() { /*****************************************************************************************************************************************************/ println!("🟢 8. Activate Link..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -570,6 +582,7 @@ async fn test_wan_link() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1399,6 +1412,15 @@ async fn test_wan_link_rejects_cyoa_interface() { ) .await; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + // Attempt to activate the link - should fail because side A now has CYOA let res = try_execute_transaction( &mut banks_client, @@ -1414,6 +1436,7 @@ async fn test_wan_link_rejects_cyoa_interface() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1766,6 +1789,15 @@ async fn test_cannot_set_cyoa_on_linked_interface() { ) .await; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -1780,6 +1812,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2291,6 +2324,16 @@ async fn test_link_delete_from_soft_drained() { .await .expect("Failed to get blockhash"); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate execute_transaction( &mut banks_client, @@ -2306,6 +2349,7 @@ async fn test_link_delete_from_soft_drained() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2414,6 +2458,16 @@ async fn test_link_delete_from_hard_drained() { .await .expect("Failed to get blockhash"); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate execute_transaction( &mut banks_client, @@ -2429,6 +2483,7 @@ async fn test_link_delete_from_hard_drained() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2540,3 +2595,148 @@ async fn test_link_create_invalid_mtu() { error_string ); } + +#[tokio::test] +async fn test_link_activation_auto_tags_unicast_default() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + // Create AdminGroupBits resource extension (required before creating topology) + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new(solana_sdk::pubkey::Pubkey::default(), false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + &payer, + ) + .await; + + // Create the "unicast-default" topology + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(unicast_default_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Activate the link — it should auto-tag with UNICAST-DEFAULT + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Verify link_topologies was set to [unicast_default_pda] + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .expect("Link not found") + .get_tunnel() + .unwrap(); + assert_eq!(link.status, LinkStatus::Activated); + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link.link_topologies should be [unicast-default PDA] after activation" + ); +} + +#[tokio::test] +async fn test_link_activation_fails_without_unicast_default() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + // Derive the unicast-default PDA without creating it + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Attempt to activate — should fail with InvalidArgument (Custom(65)) + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument (Custom(65)), got: {}", + error_string + ); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index 307179077c..21298b33f9 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -4,13 +4,17 @@ use doublezero_serviceability::{ instructions::*, pda::{ get_globalconfig_pda, get_globalstate_pda, get_program_config_pda, - get_resource_extension_pda, + get_resource_extension_pda, get_topology_pda, + }, + processors::{ + globalconfig::set::SetGlobalConfigArgs, resource::create::ResourceCreateArgs, + topology::create::TopologyCreateArgs, }, - processors::globalconfig::set::SetGlobalConfigArgs, resource::ResourceType, state::{ accountdata::AccountData, accounttype::AccountType, device::Device, globalstate::GlobalState, resource_extension::ResourceExtensionOwned, + topology::TopologyConstraint, }, }; use solana_program_test::*; @@ -553,3 +557,58 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, globalconfig_pubkey, ) } + +/// Create the AdminGroupBits resource extension and the "unicast-default" topology. +/// Returns the PDA of the "unicast-default" topology. +/// Requires that global state + global config are already initialized. +#[allow(dead_code)] +pub async fn create_unicast_default_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + globalconfig_pubkey: Pubkey, + payer: &Keypair, +) -> Pubkey { + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateResource(ResourceCreateArgs { + resource_type: ResourceType::AdminGroupBits, + }), + vec![ + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new(Pubkey::default(), false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + ], + payer, + ) + .await; + + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(unicast_default_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; + + unicast_default_pda +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index e62d1380cb..fcc12595dd 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1109,7 +1109,8 @@ async fn setup_wan_link( ) .await; - // Activate link + // Activate link (unicast-default topology must already exist at this point) + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); execute_transaction( banks_client, recent_blockhash, @@ -1124,6 +1125,7 @@ async fn setup_wan_link( AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], payer, ) @@ -1250,6 +1252,18 @@ async fn test_topology_delete_fails_when_link_references_it() { ) .await; + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + // Set up a WAN link and assign the topology to it let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1388,6 +1402,18 @@ async fn test_topology_clear_removes_from_links() { ) .await; + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + // Set up a WAN link and assign the topology to it let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1445,7 +1471,7 @@ async fn test_topology_clear_is_idempotent() { ) .await; - create_topology( + let test_topology_pda = create_topology( &mut banks_client, program_id, globalstate_pubkey, @@ -1456,15 +1482,37 @@ async fn test_topology_clear_is_idempotent() { ) .await; - // Set up a WAN link but do NOT assign the topology + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Set up a WAN link but do NOT assign the "test-topology" topology let (link_pubkey, _, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; - // Verify link has no topology assignment + // Verify link has only the unicast-default topology (auto-tagged at activation), + // NOT the "test-topology" topology let link = get_link(&mut banks_client, link_pubkey).await; - assert!(link.link_topologies.is_empty()); + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link_topologies should only contain unicast-default after activation" + ); + assert!( + !link.link_topologies.contains(&test_topology_pda), + "link_topologies should not contain test-topology" + ); - // Call clear — link does not reference topology, so nothing should change, no error + // Call clear — link does not reference "test-topology", so nothing should change, no error clear_topology( &mut banks_client, program_id, @@ -1475,11 +1523,12 @@ async fn test_topology_clear_is_idempotent() { ) .await; - // Verify link is still empty + // Verify link_topologies is unchanged (still only unicast-default) let link = get_link(&mut banks_client, link_pubkey).await; - assert!( - link.link_topologies.is_empty(), - "link_topologies should still be empty" + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link_topologies should still only contain unicast-default after no-op clear" ); println!("[PASS] test_topology_clear_is_idempotent"); @@ -1624,9 +1673,18 @@ async fn test_topology_clear_non_foundation_rejected() { async fn test_link_unicast_drained_contributor_can_set_own_link() { println!("[TEST] test_link_unicast_drained_contributor_can_set_own_link"); - let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1664,9 +1722,18 @@ async fn test_link_unicast_drained_contributor_can_set_own_link() { async fn test_link_unicast_drained_contributor_cannot_set_other_link() { println!("[TEST] test_link_unicast_drained_contributor_cannot_set_other_link"); - let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Create the link owned by payer (contributor A) let (link_pubkey, _contributor_a_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1731,9 +1798,18 @@ async fn test_link_unicast_drained_contributor_cannot_set_other_link() { async fn test_link_unicast_drained_foundation_can_set_any_link() { println!("[TEST] test_link_unicast_drained_foundation_can_set_any_link"); - let (mut banks_client, payer, program_id, globalstate_pubkey, _) = + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = setup_program_with_globalconfig().await; + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; @@ -1789,10 +1865,22 @@ async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { ) .await; + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (link_pubkey, contributor_pubkey, _, _) = setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; - // Assign a topology to the link (foundation-only) + // Assign a topology to the link (foundation-only), replacing the unicast-default auto-tag assign_link_topology( &mut banks_client, program_id, diff --git a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs index 9027fcd6c2..cf6646f746 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs @@ -342,6 +342,15 @@ async fn setup_two_devices_with_link() -> ( .await; // Activate the link (interfaces become Activated with tunnel IPs) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -356,6 +365,7 @@ async fn setup_two_devices_with_link() -> ( AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) From 02b069248586274d381742723c6a94ba508f1ae4 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 08:12:38 -0500 Subject: [PATCH 09/54] smartcontract: fix activate: add unicast-default owner check; refactor test helper usage --- .../src/processors/link/activate.rs | 5 ++ .../tests/link_wan_test.rs | 53 ++++--------------- 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index a597342f35..702319d903 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -233,6 +233,11 @@ pub fn process_activate_link( link.check_status_transition(); // Auto-tag with UNICAST-DEFAULT topology at activation + assert_eq!( + unicast_default_topology_account.owner, + program_id, + "Invalid UNICAST-DEFAULT topology account owner" + ); let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); if unicast_default_topology_account.key != &expected_unicast_default_pda || unicast_default_topology_account.data_is_empty() diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index c24b0ccac8..63f7c0980c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2609,52 +2609,20 @@ async fn test_link_activation_auto_tags_unicast_default() { tunnel_pubkey, ) = setup_link_env().await; - let recent_blockhash = banks_client - .get_latest_blockhash() - .await - .expect("Failed to get blockhash"); - - // Create AdminGroupBits resource extension (required before creating topology) - let (admin_group_bits_pda, _, _) = - get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); - - execute_transaction( + let unicast_default_pda = create_unicast_default_topology( &mut banks_client, - recent_blockhash, program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(admin_group_bits_pda, false), - AccountMeta::new(solana_sdk::pubkey::Pubkey::default(), false), - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], + globalstate_pubkey, + globalconfig_pubkey, &payer, ) .await; - // Create the "unicast-default" topology - let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); - - execute_transaction( - &mut banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { - name: "unicast-default".to_string(), - constraint: TopologyConstraint::IncludeAny, - }), - vec![ - AccountMeta::new(unicast_default_pda, false), - AccountMeta::new(admin_group_bits_pda, false), - AccountMeta::new_readonly(globalstate_pubkey, false), - ], - &payer, - ) - .await; + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); // Activate the link — it should auto-tag with UNICAST-DEFAULT execute_transaction( @@ -2712,7 +2680,8 @@ async fn test_link_activation_fails_without_unicast_default() { // Derive the unicast-default PDA without creating it let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); - // Attempt to activate — should fail with InvalidArgument (Custom(65)) + // Attempt to activate — should fail because the unicast-default account is + // system-owned (not created), triggering the owner check before key validation. let result = try_execute_transaction( &mut banks_client, recent_blockhash, @@ -2735,8 +2704,8 @@ async fn test_link_activation_fails_without_unicast_default() { let error_string = format!("{:?}", result.unwrap_err()); assert!( - error_string.contains("Custom(65)"), - "Expected InvalidArgument (Custom(65)), got: {}", + error_string.contains("ProgramFailedToComplete"), + "Expected ProgramFailedToComplete (owner check), got: {}", error_string ); } From 8a322e02c9260a4a07e531deaae869a6fe8c8a6f Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 08:17:43 -0500 Subject: [PATCH 10/54] smartcontract: fix activate: fold owner check into InvalidArgument guard; restore Custom(65) test assertion --- .../src/processors/link/activate.rs | 8 ++------ .../doublezero-serviceability/tests/link_wan_test.rs | 4 ++-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index 702319d903..ec442d6778 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -233,13 +233,9 @@ pub fn process_activate_link( link.check_status_transition(); // Auto-tag with UNICAST-DEFAULT topology at activation - assert_eq!( - unicast_default_topology_account.owner, - program_id, - "Invalid UNICAST-DEFAULT topology account owner" - ); let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); - if unicast_default_topology_account.key != &expected_unicast_default_pda + if unicast_default_topology_account.owner != program_id + || unicast_default_topology_account.key != &expected_unicast_default_pda || unicast_default_topology_account.data_is_empty() { return Err(DoubleZeroError::InvalidArgument.into()); diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 63f7c0980c..b3419b0268 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2704,8 +2704,8 @@ async fn test_link_activation_fails_without_unicast_default() { let error_string = format!("{:?}", result.unwrap_err()); assert!( - error_string.contains("ProgramFailedToComplete"), - "Expected ProgramFailedToComplete (owner check), got: {}", + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", error_string ); } From 590e0c601e38ec88e757178ea6710f930fae3ee4 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 09:12:29 -0500 Subject: [PATCH 11/54] =?UTF-8?q?smartcontract:=20fix=20lint=20=E2=80=94?= =?UTF-8?q?=20missing=20include=5Ftopologies=20fields,=20unused=20imports,?= =?UTF-8?q?=20unused=20variables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/doublezero/src/command/connect.rs | 2 ++ .../tests/link_onchain_allocation_test.rs | 4 ++-- .../programs/doublezero-serviceability/tests/link_wan_test.rs | 3 --- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/client/doublezero/src/command/connect.rs b/client/doublezero/src/command/connect.rs index df78c19513..b9ff9ee347 100644 --- a/client/doublezero/src/command/connect.rs +++ b/client/doublezero/src/command/connect.rs @@ -1072,6 +1072,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mut tenants = HashMap::new(); @@ -1397,6 +1398,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; tenants.insert(pk, tenant.clone()); (pk, tenant) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs index 37682b28fd..f10d1f0576 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs @@ -38,7 +38,7 @@ use test_helpers::*; /// Test that ActivateLink works with onchain allocation from ResourceExtension #[tokio::test] async fn test_activate_link_with_onchain_allocation() { - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -451,7 +451,7 @@ async fn test_activate_link_with_onchain_allocation() { /// Test that the legacy ActivateLink path (without onchain allocation) still works #[tokio::test] async fn test_activate_link_legacy_path() { - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index b3419b0268..b862d1de07 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -5,8 +5,6 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::interface::{update::DeviceInterfaceUpdateArgs, DeviceInterfaceUnlinkArgs}, link::{activate::*, create::*, delete::*, sethealth::LinkSetHealthArgs, update::*}, - resource::create::ResourceCreateArgs, - topology::create::TopologyCreateArgs, *, }, resource::ResourceType, @@ -18,7 +16,6 @@ use doublezero_serviceability::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, link::*, - topology::TopologyConstraint, }, }; use globalconfig::set::SetGlobalConfigArgs; From f22446db387a3a0500dc85efc132760d67b89afc Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 09:50:32 -0500 Subject: [PATCH 12/54] cli: add doublezero link topology create/delete/clear/list subcommands --- client/doublezero/src/cli/link.rs | 35 +++++- client/doublezero/src/main.rs | 8 +- smartcontract/cli/src/doublezerocommand.rs | 30 ++++- smartcontract/cli/src/lib.rs | 1 + smartcontract/cli/src/topology/clear.rs | 114 +++++++++++++++++ smartcontract/cli/src/topology/create.rs | 100 +++++++++++++++ smartcontract/cli/src/topology/delete.rs | 57 +++++++++ smartcontract/cli/src/topology/list.rs | 99 +++++++++++++++ smartcontract/cli/src/topology/mod.rs | 4 + .../sdk/rs/src/commands/link/activate.rs | 20 ++- smartcontract/sdk/rs/src/commands/mod.rs | 1 + .../sdk/rs/src/commands/topology/clear.rs | 115 ++++++++++++++++++ .../sdk/rs/src/commands/topology/create.rs | 93 ++++++++++++++ .../sdk/rs/src/commands/topology/delete.rs | 74 +++++++++++ .../sdk/rs/src/commands/topology/list.rs | 85 +++++++++++++ .../sdk/rs/src/commands/topology/mod.rs | 4 + smartcontract/sdk/rs/src/lib.rs | 3 +- 17 files changed, 833 insertions(+), 10 deletions(-) create mode 100644 smartcontract/cli/src/topology/clear.rs create mode 100644 smartcontract/cli/src/topology/create.rs create mode 100644 smartcontract/cli/src/topology/delete.rs create mode 100644 smartcontract/cli/src/topology/list.rs create mode 100644 smartcontract/cli/src/topology/mod.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/clear.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/create.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/delete.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/list.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/mod.rs diff --git a/client/doublezero/src/cli/link.rs b/client/doublezero/src/cli/link.rs index 86b41ac6b0..8e898db2f4 100644 --- a/client/doublezero/src/cli/link.rs +++ b/client/doublezero/src/cli/link.rs @@ -1,8 +1,14 @@ use clap::{Args, Subcommand}; -use doublezero_cli::link::{ - accept::AcceptLinkCliCommand, delete::*, dzx_create::CreateDZXLinkCliCommand, get::*, - latency::LinkLatencyCliCommand, list::*, sethealth::SetLinkHealthCliCommand, update::*, - wan_create::*, +use doublezero_cli::{ + link::{ + accept::AcceptLinkCliCommand, delete::*, dzx_create::CreateDZXLinkCliCommand, get::*, + latency::LinkLatencyCliCommand, list::*, sethealth::SetLinkHealthCliCommand, update::*, + wan_create::*, + }, + topology::{ + clear::ClearTopologyCliCommand, create::CreateTopologyCliCommand, + delete::DeleteTopologyCliCommand, list::ListTopologyCliCommand, + }, }; #[derive(Args, Debug)] @@ -53,4 +59,25 @@ pub enum LinkCommands { // Hidden because this is an internal/operational command not intended for general CLI users. #[clap(hide = true)] SetHealth(SetLinkHealthCliCommand), + /// Manage link topologies + #[clap()] + Topology(TopologyLinkCommand), +} + +#[derive(Args, Debug)] +pub struct TopologyLinkCommand { + #[command(subcommand)] + pub command: TopologyCommands, +} + +#[derive(Debug, Subcommand)] +pub enum TopologyCommands { + /// Create a new topology + Create(CreateTopologyCliCommand), + /// Delete a topology + Delete(DeleteTopologyCliCommand), + /// Clear a topology from links + Clear(ClearTopologyCliCommand), + /// List all topologies + List(ListTopologyCliCommand), } diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 17f5dbc8ed..d5437e422b 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -17,7 +17,7 @@ use crate::cli::{ AirdropCommands, AuthorityCommands, FeatureFlagsCommands, FoundationAllowlistCommands, GlobalConfigCommands, QaAllowlistCommands, }, - link::LinkCommands, + link::{LinkCommands, TopologyCommands}, location::LocationCommands, user::UserCommands, }; @@ -232,6 +232,12 @@ async fn main() -> eyre::Result<()> { LinkCommands::Latency(args) => args.execute(&client, &mut handle), LinkCommands::Delete(args) => args.execute(&client, &mut handle), LinkCommands::SetHealth(args) => args.execute(&client, &mut handle), + LinkCommands::Topology(args) => match args.command { + TopologyCommands::Create(args) => args.execute(&client, &mut handle), + TopologyCommands::Delete(args) => args.execute(&client, &mut handle), + TopologyCommands::Clear(args) => args.execute(&client, &mut handle), + TopologyCommands::List(args) => args.execute(&client, &mut handle), + }, }, Command::AccessPass(command) => match command.command { cli::accesspass::AccessPassCommands::Set(args) => args.execute(&client, &mut handle), diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index d9ce860377..1b1eab3126 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -99,6 +99,10 @@ use doublezero_sdk::{ remove_administrator::RemoveAdministratorTenantCommand, update::UpdateTenantCommand, update_payment_status::UpdatePaymentStatusCommand, }, + topology::{ + clear::ClearTopologyCommand, create::CreateTopologyCommand, + delete::DeleteTopologyCommand, list::ListTopologyCommand, + }, user::{ create::CreateUserCommand, create_subscribe::CreateSubscribeUserCommand, delete::DeleteUserCommand, get::GetUserCommand, list::ListUserCommand, @@ -107,7 +111,8 @@ use doublezero_sdk::{ }, telemetry::LinkLatencyStats, DZClient, Device, DoubleZeroClient, Exchange, GetGlobalConfigCommand, GetGlobalStateCommand, - GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, User, + GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, + TopologyInfo, User, }; use doublezero_serviceability::state::{ accesspass::AccessPass, accountdata::AccountData, contributor::Contributor, @@ -336,6 +341,14 @@ pub trait CliCommand { cmd: GetResourceCommand, ) -> eyre::Result<(Pubkey, ResourceExtensionOwned)>; fn close_resource(&self, cmd: CloseResourceCommand) -> eyre::Result; + + fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)>; + fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result; + fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result; + fn list_topology( + &self, + cmd: ListTopologyCommand, + ) -> eyre::Result>; } pub struct CliCommandImpl<'a> { @@ -799,4 +812,19 @@ impl CliCommand for CliCommandImpl<'_> { fn close_resource(&self, cmd: CloseResourceCommand) -> eyre::Result { cmd.execute(self.client) } + fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)> { + cmd.execute(self.client) + } + fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn list_topology( + &self, + cmd: ListTopologyCommand, + ) -> eyre::Result> { + cmd.execute(self.client) + } } diff --git a/smartcontract/cli/src/lib.rs b/smartcontract/cli/src/lib.rs index f9d4118273..4f330c9158 100644 --- a/smartcontract/cli/src/lib.rs +++ b/smartcontract/cli/src/lib.rs @@ -30,6 +30,7 @@ pub mod resource; pub mod subscribe; pub mod tenant; pub mod tests; +pub mod topology; pub mod user; pub mod util; pub mod validators; diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs new file mode 100644 index 0000000000..81a925a614 --- /dev/null +++ b/smartcontract/cli/src/topology/clear.rs @@ -0,0 +1,114 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::clear::ClearTopologyCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct ClearTopologyCliCommand { + /// Name of the topology to clear from links + #[arg(long)] + pub name: String, + /// Comma-separated list of link pubkeys to clear the topology from + #[arg(long, value_delimiter = ',')] + pub links: Vec, +} + +impl ClearTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + let link_pubkeys: Vec = self + .links + .iter() + .map(|s| { + s.parse::() + .map_err(|_| eyre::eyre!("invalid link pubkey: {}", s)) + }) + .collect::>>()?; + + let n = link_pubkeys.len(); + client.clear_topology(ClearTopologyCommand { + name: self.name.clone(), + link_pubkeys, + })?; + writeln!(out, "Cleared topology '{}' from {} link(s).", self.name, n)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use mockall::predicate::eq; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::io::Cursor; + + #[test] + fn test_clear_topology_execute_no_links() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_clear_topology() + .with(eq(ClearTopologyCommand { + name: "unicast-default".to_string(), + link_pubkeys: vec![], + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'unicast-default' from 0 link(s).")); + } + + #[test] + fn test_clear_topology_execute_with_links() { + let mut mock = MockCliCommand::new(); + let link1 = Pubkey::new_unique(); + let link2 = Pubkey::new_unique(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_clear_topology() + .with(eq(ClearTopologyCommand { + name: "unicast-default".to_string(), + link_pubkeys: vec![link1, link2], + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec![link1.to_string(), link2.to_string()], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'unicast-default' from 2 link(s).")); + } + + #[test] + fn test_clear_topology_invalid_pubkey() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec!["not-a-pubkey".to_string()], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + } +} diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs new file mode 100644 index 0000000000..b89b7af449 --- /dev/null +++ b/smartcontract/cli/src/topology/create.rs @@ -0,0 +1,100 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::create::CreateTopologyCommand; +use doublezero_serviceability::state::topology::TopologyConstraint; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct CreateTopologyCliCommand { + /// Name of the topology (max 32 bytes) + #[arg(long)] + pub name: String, + /// Constraint type: include-any or exclude + #[arg(long, value_parser = parse_constraint)] + pub constraint: TopologyConstraint, +} + +fn parse_constraint(s: &str) -> Result { + match s { + "include-any" => Ok(TopologyConstraint::IncludeAny), + "exclude" => Ok(TopologyConstraint::Exclude), + _ => Err(format!( + "invalid constraint '{}': expected 'include-any' or 'exclude'", + s + )), + } +} + +impl CreateTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + let (_, topology_pda) = client.create_topology(CreateTopologyCommand { + name: self.name.clone(), + constraint: self.constraint, + })?; + writeln!( + out, + "Created topology '{}' successfully. PDA: {}", + self.name, topology_pda + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use doublezero_serviceability::state::topology::TopologyConstraint; + use mockall::predicate::eq; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::io::Cursor; + + #[test] + fn test_create_topology_execute_success() { + let mut mock = MockCliCommand::new(); + let topology_pda = Pubkey::new_unique(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_create_topology() + .with(eq(CreateTopologyCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + })) + .returning(move |_| Ok((Signature::new_unique(), topology_pda))); + + let cmd = CreateTopologyCliCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Created topology 'unicast-default' successfully.")); + assert!(output.contains(&topology_pda.to_string())); + } + + #[test] + fn test_parse_constraint_include_any() { + assert_eq!( + parse_constraint("include-any"), + Ok(TopologyConstraint::IncludeAny) + ); + } + + #[test] + fn test_parse_constraint_exclude() { + assert_eq!(parse_constraint("exclude"), Ok(TopologyConstraint::Exclude)); + } + + #[test] + fn test_parse_constraint_invalid() { + assert!(parse_constraint("unknown").is_err()); + } +} diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs new file mode 100644 index 0000000000..034404b325 --- /dev/null +++ b/smartcontract/cli/src/topology/delete.rs @@ -0,0 +1,57 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::delete::DeleteTopologyCommand; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct DeleteTopologyCliCommand { + /// Name of the topology to delete + #[arg(long)] + pub name: String, +} + +impl DeleteTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + client.delete_topology(DeleteTopologyCommand { + name: self.name.clone(), + })?; + writeln!(out, "Deleted topology '{}' successfully.", self.name)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use mockall::predicate::eq; + use solana_sdk::signature::Signature; + use std::io::Cursor; + + #[test] + fn test_delete_topology_execute_success() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_delete_topology() + .with(eq(DeleteTopologyCommand { + name: "unicast-default".to_string(), + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = DeleteTopologyCliCommand { + name: "unicast-default".to_string(), + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Deleted topology 'unicast-default' successfully.")); + } +} diff --git a/smartcontract/cli/src/topology/list.rs b/smartcontract/cli/src/topology/list.rs new file mode 100644 index 0000000000..dbcfffd27c --- /dev/null +++ b/smartcontract/cli/src/topology/list.rs @@ -0,0 +1,99 @@ +use crate::doublezerocommand::CliCommand; +use clap::Args; +use doublezero_sdk::commands::topology::list::ListTopologyCommand; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct ListTopologyCliCommand {} + +impl ListTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + let topologies = client.list_topology(ListTopologyCommand)?; + + if topologies.is_empty() { + writeln!(out, "No topologies found.")?; + return Ok(()); + } + + let mut entries: Vec<_> = topologies.into_values().collect(); + entries.sort_by_key(|t| t.admin_group_bit); + + writeln!( + out, + "{:<32} {:>3} {:>4} {:>5} {:?}", + "NAME", "BIT", "ALGO", "COLOR", "CONSTRAINT" + )?; + for t in &entries { + writeln!( + out, + "{:<32} {:>3} {:>4} {:>5} {:?}", + t.name, + t.admin_group_bit, + t.flex_algo_number, + t.admin_group_bit as u16 + 1, + t.constraint + )?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use doublezero_serviceability::state::{ + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + use solana_sdk::pubkey::Pubkey; + use std::{collections::HashMap, io::Cursor}; + + #[test] + fn test_list_topology_empty() { + let mut mock = MockCliCommand::new(); + + mock.expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand {}; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("No topologies found.")); + } + + #[test] + fn test_list_topology_with_entries() { + let mut mock = MockCliCommand::new(); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + mock.expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(Pubkey::new_unique(), topology.clone()); + Ok(map) + }); + + let cmd = ListTopologyCliCommand {}; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("unicast-default")); + assert!(output.contains("128")); + } +} diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs new file mode 100644 index 0000000000..9c8c1e08a5 --- /dev/null +++ b/smartcontract/cli/src/topology/mod.rs @@ -0,0 +1,4 @@ +pub mod clear; +pub mod create; +pub mod delete; +pub mod list; diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index 628a5574ed..48ad41e7fa 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -4,8 +4,11 @@ use crate::{ }; use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ - instructions::DoubleZeroInstruction, pda::get_resource_extension_pda, - processors::link::activate::LinkActivateArgs, resource::ResourceType, state::link::LinkStatus, + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::link::activate::LinkActivateArgs, + resource::ResourceType, + state::link::LinkStatus, }; use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; @@ -61,6 +64,11 @@ impl ActivateLinkCommand { accounts.push(AccountMeta::new(link_ids_ext, false)); } + // Always include the unicast-default topology account (required by the on-chain program) + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); + accounts.push(AccountMeta::new_readonly(unicast_default_pda, false)); + client.execute_transaction( DoubleZeroInstruction::ActivateLink(LinkActivateArgs { tunnel_id: self.tunnel_id, @@ -81,7 +89,7 @@ mod tests { use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::{get_globalstate_pda, get_resource_extension_pda}, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, processors::link::activate::LinkActivateArgs, resource::ResourceType, state::{ @@ -98,6 +106,8 @@ mod tests { let mut client = create_test_client(); let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); let link_pubkey = Pubkey::new_unique(); let side_a_pk = Pubkey::new_unique(); let side_z_pk = Pubkey::new_unique(); @@ -150,6 +160,7 @@ mod tests { AccountMeta::new(side_a_pk, false), AccountMeta::new(side_z_pk, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ]), ) .returning(|_, _| Ok(Signature::new_unique())); @@ -172,6 +183,8 @@ mod tests { let mut client = create_test_client(); let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); let link_pubkey = Pubkey::new_unique(); let side_a_pk = Pubkey::new_unique(); let side_z_pk = Pubkey::new_unique(); @@ -230,6 +243,7 @@ mod tests { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_ext, false), AccountMeta::new(link_ids_ext, false), + AccountMeta::new_readonly(unicast_default_pda, false), ]), ) .returning(|_, _| Ok(Signature::new_unique())); diff --git a/smartcontract/sdk/rs/src/commands/mod.rs b/smartcontract/sdk/rs/src/commands/mod.rs index b5f49ff28b..77bc50a37e 100644 --- a/smartcontract/sdk/rs/src/commands/mod.rs +++ b/smartcontract/sdk/rs/src/commands/mod.rs @@ -14,4 +14,5 @@ pub mod permission; pub mod programconfig; pub mod resource; pub mod tenant; +pub mod topology; pub mod user; diff --git a/smartcontract/sdk/rs/src/commands/topology/clear.rs b/smartcontract/sdk/rs/src/commands/topology/clear.rs new file mode 100644 index 0000000000..6e3c48b971 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/clear.rs @@ -0,0 +1,115 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_topology_pda, + processors::topology::clear::TopologyClearArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct ClearTopologyCommand { + pub name: String, + pub link_pubkeys: Vec, +} + +impl ClearTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + + let mut accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + + for link_pk in &self.link_pubkeys { + accounts.push(AccountMeta::new(*link_pk, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: self.name.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::clear::ClearTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_topology_pda}, + processors::topology::clear::TopologyClearArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_topology_clear_command_no_links() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "my-topology".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = ClearTopologyCommand { + name: "my-topology".to_string(), + link_pubkeys: vec![], + } + .execute(&client); + + assert!(res.is_ok()); + } + + #[test] + fn test_commands_topology_clear_command_with_links() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let link1 = Pubkey::new_unique(); + let link2 = Pubkey::new_unique(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "my-topology".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(link1, false), + AccountMeta::new(link2, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = ClearTopologyCommand { + name: "my-topology".to_string(), + link_pubkeys: vec![link1, link2], + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs new file mode 100644 index 0000000000..c8efc3668e --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -0,0 +1,93 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::topology::create::TopologyCreateArgs, + resource::ResourceType, + state::topology::TopologyConstraint, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct CreateTopologyCommand { + pub name: String, + pub constraint: TopologyConstraint, +} + +impl CreateTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result<(Signature, Pubkey)> { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + + client + .execute_transaction( + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: self.name.clone(), + constraint: self.constraint, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + ) + .map(|sig| (sig, topology_pda)) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::create::CreateTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, + processors::topology::create::TopologyCreateArgs, + resource::ResourceType, + state::topology::TopologyConstraint, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_topology_create_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + })), + predicate::eq(vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = CreateTopologyCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + } + .execute(&client); + + assert!(res.is_ok()); + let (_, pda) = res.unwrap(); + assert_eq!(pda, topology_pda); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/delete.rs b/smartcontract/sdk/rs/src/commands/topology/delete.rs new file mode 100644 index 0000000000..df5d0ec6e7 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/delete.rs @@ -0,0 +1,74 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_topology_pda, + processors::topology::delete::TopologyDeleteArgs, +}; +use solana_sdk::{instruction::AccountMeta, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct DeleteTopologyCommand { + pub name: String, +} + +impl DeleteTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + + client.execute_transaction( + DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: self.name.clone(), + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::delete::DeleteTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_topology_pda}, + processors::topology::delete::TopologyDeleteArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_topology_delete_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: "unicast-default".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = DeleteTopologyCommand { + name: "unicast-default".to_string(), + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/list.rs b/smartcontract/sdk/rs/src/commands/topology/list.rs new file mode 100644 index 0000000000..e19956b09c --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/list.rs @@ -0,0 +1,85 @@ +use crate::DoubleZeroClient; +use doublezero_serviceability::{ + error::DoubleZeroError, + state::{accountdata::AccountData, accounttype::AccountType, topology::TopologyInfo}, +}; +use solana_sdk::pubkey::Pubkey; +use std::collections::HashMap; + +#[derive(Debug, PartialEq, Clone)] +pub struct ListTopologyCommand; + +impl ListTopologyCommand { + pub fn execute( + &self, + client: &dyn DoubleZeroClient, + ) -> eyre::Result> { + client + .gets(AccountType::Topology)? + .into_iter() + .map(|(k, v)| match v { + AccountData::Topology(topology) => Ok((k, topology)), + _ => Err(DoubleZeroError::InvalidAccountType.into()), + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{commands::topology::list::ListTopologyCommand, tests::utils::create_test_client}; + use doublezero_serviceability::state::{ + accountdata::AccountData, + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + use mockall::predicate; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_commands_topology_list_command() { + let mut client = create_test_client(); + + let topology1_pubkey = Pubkey::new_unique(); + let topology1 = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + let topology2_pubkey = Pubkey::new_unique(); + let topology2 = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 2, + name: "exclude-test".to_string(), + admin_group_bit: 2, + flex_algo_number: 130, + constraint: TopologyConstraint::Exclude, + }; + + client + .expect_gets() + .with(predicate::eq(AccountType::Topology)) + .returning(move |_| { + let mut topologies = HashMap::new(); + topologies.insert(topology1_pubkey, AccountData::Topology(topology1.clone())); + topologies.insert(topology2_pubkey, AccountData::Topology(topology2.clone())); + Ok(topologies) + }); + + let res = ListTopologyCommand.execute(&client); + + assert!(res.is_ok()); + let list = res.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.contains_key(&topology1_pubkey)); + assert!(list.contains_key(&topology2_pubkey)); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/mod.rs b/smartcontract/sdk/rs/src/commands/topology/mod.rs new file mode 100644 index 0000000000..9c8c1e08a5 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/mod.rs @@ -0,0 +1,4 @@ +pub mod clear; +pub mod create; +pub mod delete; +pub mod list; diff --git a/smartcontract/sdk/rs/src/lib.rs b/smartcontract/sdk/rs/src/lib.rs index 904dfac968..5f9e21d07b 100644 --- a/smartcontract/sdk/rs/src/lib.rs +++ b/smartcontract/sdk/rs/src/lib.rs @@ -8,7 +8,7 @@ pub use doublezero_serviceability::{ pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, get_location_pda, get_multicastgroup_pda, get_permission_pda, get_resource_extension_pda, - get_tenant_pda, get_user_old_pda, + get_tenant_pda, get_topology_pda, get_user_old_pda, }, programversion::ProgramVersion, resource::{IdOrIp, ResourceType}, @@ -30,6 +30,7 @@ pub use doublezero_serviceability::{ programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, + topology::{TopologyConstraint, TopologyInfo}, user::{BGPStatus, User, UserCYOA, UserStatus, UserType}, }, }; From 36aa817463a8a9eb81fecdce92460f9ae6e54ade Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 10:08:04 -0500 Subject: [PATCH 13/54] cli: topology delete guard referencing links; list shows link counts and --json --- smartcontract/cli/src/topology/delete.rs | 92 +++++++++- smartcontract/cli/src/topology/list.rs | 204 ++++++++++++++++++++--- 2 files changed, 272 insertions(+), 24 deletions(-) diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs index 034404b325..487f4d23f0 100644 --- a/smartcontract/cli/src/topology/delete.rs +++ b/smartcontract/cli/src/topology/delete.rs @@ -3,7 +3,10 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, }; use clap::Args; -use doublezero_sdk::commands::topology::delete::DeleteTopologyCommand; +use doublezero_sdk::{ + commands::{link::list::ListLinkCommand, topology::delete::DeleteTopologyCommand}, + get_topology_pda, +}; use std::io::Write; #[derive(Args, Debug)] @@ -17,6 +20,23 @@ impl DeleteTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + // Guard: check if any links still reference this topology + let program_id = client.get_program_id(); + let topology_pda = get_topology_pda(&program_id, &self.name).0; + let links = client.list_link(ListLinkCommand)?; + let referencing_count = links + .values() + .filter(|link| link.link_topologies.contains(&topology_pda)) + .count(); + if referencing_count > 0 { + return Err(eyre::eyre!( + "Cannot delete topology '{}': {} link(s) still reference it. Run 'doublezero link topology clear --name {}' first.", + self.name, + referencing_count, + self.name, + )); + } + client.delete_topology(DeleteTopologyCommand { name: self.name.clone(), })?; @@ -29,16 +49,27 @@ impl DeleteTopologyCliCommand { #[cfg(test)] mod tests { use super::*; - use crate::doublezerocommand::MockCliCommand; + use crate::{doublezerocommand::MockCliCommand, tests::utils::create_test_client}; + use doublezero_sdk::{ + commands::topology::delete::DeleteTopologyCommand, get_topology_pda, Link, LinkLinkType, + LinkStatus, + }; + use doublezero_serviceability::state::{ + accounttype::AccountType, + link::{LinkDesiredStatus, LinkHealth}, + }; use mockall::predicate::eq; - use solana_sdk::signature::Signature; - use std::io::Cursor; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::{collections::HashMap, io::Cursor}; #[test] fn test_delete_topology_execute_success() { let mut mock = MockCliCommand::new(); mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_get_program_id() + .returning(|| Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah")); + mock.expect_list_link().returning(|_| Ok(HashMap::new())); mock.expect_delete_topology() .with(eq(DeleteTopologyCommand { name: "unicast-default".to_string(), @@ -54,4 +85,57 @@ mod tests { let output = String::from_utf8(out.into_inner()).unwrap(); assert!(output.contains("Deleted topology 'unicast-default' successfully.")); } + + #[test] + fn test_delete_topology_blocked_by_referencing_links() { + let mut client = create_test_client(); + + client.expect_check_requirements().returning(|_| Ok(())); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let topology_pda = get_topology_pda(&program_id, "unicast-default").0; + + let link = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "link1".to_string(), + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 0, + jitter_ns: 0, + delay_override_ns: 0, + tunnel_id: 1, + tunnel_net: "10.0.0.0/30".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::new_unique(), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![topology_pda], + unicast_drained: false, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(Pubkey::new_unique(), link.clone()); + Ok(links) + }); + + let cmd = DeleteTopologyCliCommand { + name: "unicast-default".to_string(), + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Cannot delete topology 'unicast-default'")); + assert!(err.contains("1 link(s) still reference it")); + assert!(err.contains("doublezero link topology clear --name unicast-default")); + } } diff --git a/smartcontract/cli/src/topology/list.rs b/smartcontract/cli/src/topology/list.rs index dbcfffd27c..9f67f462a3 100644 --- a/smartcontract/cli/src/topology/list.rs +++ b/smartcontract/cli/src/topology/list.rs @@ -1,10 +1,28 @@ use crate::doublezerocommand::CliCommand; use clap::Args; -use doublezero_sdk::commands::topology::list::ListTopologyCommand; +use doublezero_sdk::{ + commands::{link::list::ListLinkCommand, topology::list::ListTopologyCommand}, + get_topology_pda, +}; +use serde::Serialize; use std::io::Write; #[derive(Args, Debug)] -pub struct ListTopologyCliCommand {} +pub struct ListTopologyCliCommand { + /// Output as pretty JSON. + #[arg(long, default_value_t = false)] + pub json: bool, +} + +#[derive(Serialize)] +pub struct TopologyDisplay { + pub name: String, + pub bit: u8, + pub algo: u8, + pub color: u16, + pub constraint: String, + pub links: usize, +} impl ListTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { @@ -15,23 +33,49 @@ impl ListTopologyCliCommand { return Ok(()); } - let mut entries: Vec<_> = topologies.into_values().collect(); - entries.sort_by_key(|t| t.admin_group_bit); + let links = client.list_link(ListLinkCommand)?; + let program_id = client.get_program_id(); + + let mut entries: Vec<_> = topologies.into_iter().collect(); + entries.sort_by_key(|(_, t)| t.admin_group_bit); + + let displays: Vec = entries + .iter() + .map(|(pda, t)| { + // Count how many links reference this topology PDA. The topology_pda + // is derived from name, but we already have the PDA as the map key. + let _ = get_topology_pda(&program_id, &t.name); // kept for symmetry; use key directly + let link_count = links + .values() + .filter(|link| link.link_topologies.contains(pda)) + .count(); + TopologyDisplay { + name: t.name.clone(), + bit: t.admin_group_bit, + algo: t.flex_algo_number, + color: t.admin_group_bit as u16 + 1, + constraint: format!("{:?}", t.constraint), + links: link_count, + } + }) + .collect(); + + if self.json { + serde_json::to_writer_pretty(&mut *out, &displays)?; + writeln!(out)?; + return Ok(()); + } writeln!( out, - "{:<32} {:>3} {:>4} {:>5} {:?}", - "NAME", "BIT", "ALGO", "COLOR", "CONSTRAINT" + "{:<32} {:>3} {:>4} {:>5} {:>5} {:?}", + "NAME", "BIT", "ALGO", "COLOR", "LINKS", "CONSTRAINT" )?; - for t in &entries { + for d in &displays { writeln!( out, - "{:<32} {:>3} {:>4} {:>5} {:?}", - t.name, - t.admin_group_bit, - t.flex_algo_number, - t.admin_group_bit as u16 + 1, - t.constraint + "{:<32} {:>3} {:>4} {:>5} {:>5} {}", + d.name, d.bit, d.algo, d.color, d.links, d.constraint, )?; } @@ -42,9 +86,11 @@ impl ListTopologyCliCommand { #[cfg(test)] mod tests { use super::*; - use crate::doublezerocommand::MockCliCommand; + use crate::{doublezerocommand::MockCliCommand, tests::utils::create_test_client}; + use doublezero_sdk::{get_topology_pda, Link, LinkLinkType, LinkStatus}; use doublezero_serviceability::state::{ accounttype::AccountType, + link::{LinkDesiredStatus, LinkHealth}, topology::{TopologyConstraint, TopologyInfo}, }; use solana_sdk::pubkey::Pubkey; @@ -58,7 +104,7 @@ mod tests { .with(mockall::predicate::eq(ListTopologyCommand)) .returning(|_| Ok(HashMap::new())); - let cmd = ListTopologyCliCommand {}; + let cmd = ListTopologyCliCommand { json: false }; let mut out = Cursor::new(Vec::new()); let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); @@ -68,7 +114,10 @@ mod tests { #[test] fn test_list_topology_with_entries() { - let mut mock = MockCliCommand::new(); + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); let topology = TopologyInfo { account_type: AccountType::Topology, @@ -80,20 +129,135 @@ mod tests { constraint: TopologyConstraint::IncludeAny, }; - mock.expect_list_topology() + client + .expect_list_topology() .with(mockall::predicate::eq(ListTopologyCommand)) .returning(move |_| { let mut map = HashMap::new(); - map.insert(Pubkey::new_unique(), topology.clone()); + map.insert(topology_pda, topology.clone()); Ok(map) }); - let cmd = ListTopologyCliCommand {}; + client.expect_list_link().returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand { json: false }; let mut out = Cursor::new(Vec::new()); - let result = cmd.execute(&mock, &mut out); + let result = cmd.execute(&client, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); assert!(output.contains("unicast-default")); assert!(output.contains("128")); + assert!(output.contains("LINKS")); + } + + #[test] + fn test_list_topology_link_count() { + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + client + .expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(topology_pda, topology.clone()); + Ok(map) + }); + + let link = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "link1".to_string(), + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 0, + jitter_ns: 0, + delay_override_ns: 0, + tunnel_id: 1, + tunnel_net: "10.0.0.0/30".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::new_unique(), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![topology_pda], + unicast_drained: false, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(Pubkey::new_unique(), link.clone()); + Ok(links) + }); + + let cmd = ListTopologyCliCommand { json: false }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + // The link count should be 1 + assert!( + output.contains(" 1"), + "expected link count 1 in output: {output}" + ); + } + + #[test] + fn test_list_topology_json_output() { + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + client + .expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(topology_pda, topology.clone()); + Ok(map) + }); + + client.expect_list_link().returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand { json: true }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON"); + assert!(parsed.is_array()); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "unicast-default"); + assert_eq!(arr[0]["links"], 0); } } From 418f3d79c1662610c95d7f3ff8f0f8df1daef189 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 10:26:56 -0500 Subject: [PATCH 14/54] cli: add --include-topologies to doublezero tenant update --- smartcontract/cli/src/tenant/get.rs | 7 ++++ smartcontract/cli/src/tenant/list.rs | 11 +++++-- smartcontract/cli/src/tenant/update.rs | 33 ++++++++++++++++++- .../sdk/rs/src/commands/tenant/update.rs | 3 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 0b1e82acb7..4b6353353a 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -30,6 +30,7 @@ struct TenantDisplay { pub administrators: String, pub token_account: String, pub reference_count: u32, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } @@ -56,6 +57,12 @@ impl GetTenantCliCommand { .join(", "), token_account: tenant.token_account.to_string(), reference_count: tenant.reference_count, + include_topologies: tenant + .include_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), owner: tenant.owner, }; diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index 20d06a8e46..6740eeee5d 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -25,6 +25,7 @@ pub struct TenantDisplay { pub vrf_id: u16, pub metro_routing: bool, pub route_liveness: bool, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } @@ -41,6 +42,12 @@ impl ListTenantCliCommand { vrf_id: tenant.vrf_id, metro_routing: tenant.metro_routing, route_liveness: tenant.route_liveness, + include_topologies: tenant + .include_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), owner: tenant.owner, }) .collect(); @@ -109,7 +116,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - " account | code | vrf_id | metro_routing | route_liveness | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" + " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" ); let mut output = Vec::new(); @@ -122,7 +129,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" + "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" ); } } diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index fbd8cfc83b..acaeb05bd7 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -4,7 +4,10 @@ use crate::{ validators::validate_pubkey_or_code, }; use clap::Args; -use doublezero_sdk::commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}; +use doublezero_sdk::{ + commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}, + get_topology_pda, +}; use doublezero_serviceability::state::tenant::{FlatPerEpochConfig, TenantBillingConfig}; use solana_sdk::pubkey::Pubkey; use std::{io::Write, str::FromStr}; @@ -29,6 +32,9 @@ pub struct UpdateTenantCliCommand { /// Flat billing rate per epoch (in lamports) #[arg(long)] pub billing_rate: Option, + /// Comma-separated topology names to assign to this tenant (foundation-only). Use "default" to clear. + #[arg(long)] + pub include_topologies: Option, } impl UpdateTenantCliCommand { @@ -54,6 +60,28 @@ impl UpdateTenantCliCommand { }) }); + let include_topologies = if let Some(ref topo_arg) = self.include_topologies { + if topo_arg == "default" { + Some(vec![]) + } else { + let program_id = client.get_program_id(); + let pubkeys: eyre::Result> = topo_arg + .split(',') + .map(|name| { + let name = name.trim(); + let pda = get_topology_pda(&program_id, name).0; + client + .get_account(pda) + .map_err(|_| eyre::eyre!("Topology '{}' not found", name))?; + Ok(pda) + }) + .collect(); + Some(pubkeys?) + } + } else { + None + }; + let signature = client.update_tenant(UpdateTenantCommand { tenant_pubkey, vrf_id: self.vrf_id, @@ -61,6 +89,7 @@ impl UpdateTenantCliCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing, + include_topologies, })?; writeln!(out, "Signature: {signature}")?; @@ -133,6 +162,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing: None, + include_topologies: None, })) .returning(move |_| Ok(signature)); @@ -145,6 +175,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing_rate: None, + include_topologies: None, } .execute(&client, &mut output); assert!(res.is_ok()); diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index b0b511e37d..97a7eb109d 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -13,6 +13,7 @@ pub struct UpdateTenantCommand { pub metro_routing: Option, pub route_liveness: Option, pub billing: Option, + pub include_topologies: Option>, } impl UpdateTenantCommand { @@ -26,7 +27,7 @@ impl UpdateTenantCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing: self.billing, - include_topologies: None, + include_topologies: self.include_topologies.clone(), }), vec![ AccountMeta::new(self.tenant_pubkey, false), From 665b7314dbbc30c2ed4b78f49225fbda386524c9 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 10:39:40 -0500 Subject: [PATCH 15/54] cli: add --link-topology and --unicast-drained to doublezero link update --- smartcontract/cli/src/link/get.rs | 9 ++++++ smartcontract/cli/src/link/list.rs | 13 +++++++-- smartcontract/cli/src/link/update.rs | 29 +++++++++++++++++++ .../sdk/rs/src/commands/link/update.rs | 6 ++-- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 1f4eb4f067..1bb008257e 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -47,6 +47,8 @@ struct LinkDisplay { pub status: String, pub health: String, pub owner: String, + pub link_topologies: String, + pub unicast_drained: bool, } impl GetLinkCliCommand { @@ -92,6 +94,13 @@ impl GetLinkCliCommand { status: link.status.to_string(), health: link.link_health.to_string(), owner: link.owner.to_string(), + link_topologies: link + .link_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), + unicast_drained: link.unicast_drained, }; if self.json { diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 52ebeb472d..52ce9cfac4 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -92,6 +92,8 @@ pub struct LinkDisplay { pub health: LinkHealth, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, + pub link_topologies: String, + pub unicast_drained: bool, } impl ListLinkCliCommand { @@ -217,6 +219,13 @@ impl ListLinkCliCommand { status: link.status, health: link.link_health, owner: link.owner, + link_topologies: link + .link_topologies + .iter() + .map(|pk| pk.to_string()) + .collect::>() + .join(", "), + unicast_drained: link.unicast_drained, } }) .collect(); @@ -407,7 +416,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 \n"); + assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | | false \n"); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -428,7 +437,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\"}]\n"); + assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"\",\"unicast_drained\":false}]\n"); } #[test] diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 0c4f78c515..1789e93210 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -59,6 +59,12 @@ pub struct UpdateLinkCliCommand { /// Reassign tunnel network (foundation-only, e.g. 172.16.1.100/31) #[arg(long)] pub tunnel_net: Option, + /// Topology name to tag this link with (foundation-only). Use "default" to clear. + #[arg(long)] + pub link_topology: Option, + /// Mark this link as unicast-drained (contributor or foundation) + #[arg(long)] + pub unicast_drained: Option, /// Wait for the device to be activated #[arg(short, long, default_value_t = false)] pub wait: bool, @@ -115,6 +121,21 @@ impl UpdateLinkCliCommand { } } + let link_topologies = if let Some(ref topology_name) = self.link_topology { + if topology_name == "default" { + Some(vec![]) + } else { + let (topology_pda, _) = + doublezero_sdk::get_topology_pda(&client.get_program_id(), topology_name); + client + .get_account(topology_pda) + .map_err(|_| eyre::eyre!("Topology '{}' not found", topology_name))?; + Some(vec![topology_pda]) + } + } else { + None + }; + let signature = client.update_link(UpdateLinkCommand { pubkey, code: self.code.clone(), @@ -133,6 +154,8 @@ impl UpdateLinkCliCommand { desired_status: self.desired_status, tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, + link_topologies, + unicast_drained: self.unicast_drained, })?; writeln!(out, "Signature: {signature}",)?; @@ -288,6 +311,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topologies: None, + unicast_drained: None, })) .returning(move |_| Ok(signature)); @@ -307,6 +332,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); @@ -332,6 +359,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); diff --git a/smartcontract/sdk/rs/src/commands/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 192f6ee516..28b3fb82cd 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -27,6 +27,8 @@ pub struct UpdateLinkCommand { pub desired_status: Option, pub tunnel_id: Option, pub tunnel_net: Option, + pub link_topologies: Option>, + pub unicast_drained: Option, } impl UpdateLinkCommand { @@ -100,8 +102,8 @@ impl UpdateLinkCommand { tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, use_onchain_allocation, - link_topologies: None, - unicast_drained: None, + link_topologies: self.link_topologies.clone(), + unicast_drained: self.unicast_drained, }), accounts, ) From c9dc2dddfdcde35557ef795eb508ecdea4ad4324 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 11:32:24 -0500 Subject: [PATCH 16/54] cli: resolve topology names in link/tenant get and list display --- smartcontract/cli/src/link/get.rs | 39 +++++++++++++++----- smartcontract/cli/src/link/list.rs | 54 ++++++++++++++++++++++------ smartcontract/cli/src/tenant/get.rs | 39 +++++++++++++++----- smartcontract/cli/src/tenant/list.rs | 47 ++++++++++++++++++------ 4 files changed, 143 insertions(+), 36 deletions(-) diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 1bb008257e..36b04f59d8 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::link::get::GetLinkCommand; +use doublezero_sdk::{commands::link::get::GetLinkCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -51,12 +51,36 @@ struct LinkDisplay { pub unicast_drained: bool, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl GetLinkCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, link) = client.get_link(GetLinkCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = LinkDisplay { account: pubkey.to_string(), code: link.code, @@ -94,12 +118,7 @@ impl GetLinkCliCommand { status: link.status.to_string(), health: link.link_health.to_string(), owner: link.owner.to_string(), - link_topologies: link - .link_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), unicast_drained: link.unicast_drained, }; @@ -135,6 +154,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_link_get() { @@ -252,6 +272,9 @@ mod tests { pubkey_or_code: device2_pk.to_string(), })) .returning(move |_| Ok((device2_pk, device2.clone()))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Expected failure let mut output = Vec::new(); diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 52ce9cfac4..623dc91d5e 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -6,13 +6,14 @@ use doublezero_sdk::{ contributor::{get::GetContributorCommand, list::ListContributorCommand}, device::list::ListDeviceCommand, link::list::ListLinkCommand, + topology::list::ListTopologyCommand, }, - Link, LinkLinkType, LinkStatus, + Link, LinkLinkType, LinkStatus, TopologyInfo, }; use doublezero_serviceability::state::link::{LinkDesiredStatus, LinkHealth}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{io::Write, str::FromStr}; +use std::{collections::HashMap, io::Write, str::FromStr}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -96,11 +97,34 @@ pub struct LinkDisplay { pub unicast_drained: bool, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl ListLinkCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let contributors = client.list_contributor(ListContributorCommand {})?; let devices = client.list_device(ListDeviceCommand)?; let mut links = client.list_link(ListLinkCommand)?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); // Filter by contributor if specified if let Some(contributor_filter) = &self.contributor { @@ -219,12 +243,7 @@ impl ListLinkCliCommand { status: link.status, health: link.link_health, owner: link.owner, - link_topologies: link - .link_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), unicast_drained: link.unicast_drained, } }) @@ -396,6 +415,9 @@ mod tests { tunnels.insert(tunnel1_pubkey, tunnel1.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -416,7 +438,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | | false \n"); + assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | default | false \n"); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -437,7 +459,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"\",\"unicast_drained\":false}]\n"); + assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"default\",\"unicast_drained\":false}]\n"); } #[test] @@ -621,6 +643,9 @@ mod tests { tunnels.insert(tunnel2_pubkey, tunnel2.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -800,6 +825,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by link_type=WAN (should return only link1) let mut output = Vec::new(); @@ -979,6 +1007,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by side_a=device_ams (should return only link1) let mut output = Vec::new(); @@ -1125,6 +1156,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by code=production (should return only link1) let mut output = Vec::new(); diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 4b6353353a..fecd472d52 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::get::GetTenantCommand; +use doublezero_sdk::{commands::tenant::get::GetTenantCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -35,12 +35,36 @@ struct TenantDisplay { pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl GetTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, tenant) = client.get_tenant(GetTenantCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = TenantDisplay { account: pubkey, code: tenant.code, @@ -57,12 +81,7 @@ impl GetTenantCliCommand { .join(", "), token_account: tenant.token_account.to_string(), reference_count: tenant.reference_count, - include_topologies: tenant - .include_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + include_topologies: resolve_topology_names(&tenant.include_topologies, &topology_map), owner: tenant.owner, }; @@ -91,6 +110,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_tenant_get() { @@ -130,6 +150,9 @@ mod tests { client .expect_get_tenant() .returning(move |_| Err(eyre::eyre!("not found"))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ // Expected failure diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index 6740eeee5d..356c6f3ea5 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -1,10 +1,13 @@ use crate::doublezerocommand::CliCommand; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::list::ListTenantCommand; +use doublezero_sdk::{ + commands::{tenant::list::ListTenantCommand, topology::list::ListTopologyCommand}, + TopologyInfo, +}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -30,9 +33,32 @@ pub struct TenantDisplay { pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl ListTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let tenants = client.list_tenant(ListTenantCommand {})?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); let mut tenant_displays: Vec = tenants .into_iter() @@ -42,12 +68,10 @@ impl ListTenantCliCommand { vrf_id: tenant.vrf_id, metro_routing: tenant.metro_routing, route_liveness: tenant.route_liveness, - include_topologies: tenant - .include_topologies - .iter() - .map(|pk| pk.to_string()) - .collect::>() - .join(", "), + include_topologies: resolve_topology_names( + &tenant.include_topologies, + &topology_map, + ), owner: tenant.owner, }) .collect(); @@ -104,6 +128,9 @@ mod tests { client .expect_list_tenant() .returning(move |_| Ok(HashMap::from([(tenant1_pubkey, tenant1.clone())]))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ let mut output = Vec::new(); @@ -116,7 +143,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" + " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | default | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" ); let mut output = Vec::new(); @@ -129,7 +156,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" + "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"default\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" ); } } From eba7807ed1195073de787ae64db3c6c51c3ea03e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 11:45:44 -0500 Subject: [PATCH 17/54] cli: add doublezero-admin migrate command for RFC-18 link topology backfill --- .../doublezero-admin/src/cli/command.rs | 7 +- .../doublezero-admin/src/cli/migrate.rs | 130 ++++++++++++++++++ controlplane/doublezero-admin/src/cli/mod.rs | 1 + controlplane/doublezero-admin/src/main.rs | 1 + 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 controlplane/doublezero-admin/src/cli/migrate.rs diff --git a/controlplane/doublezero-admin/src/cli/command.rs b/controlplane/doublezero-admin/src/cli/command.rs index 8d3826ed50..aa84e6f51d 100644 --- a/controlplane/doublezero-admin/src/cli/command.rs +++ b/controlplane/doublezero-admin/src/cli/command.rs @@ -2,8 +2,8 @@ use super::{multicast::MulticastCliCommand, sentinel::SentinelCliCommand}; use crate::cli::{ accesspass::AccessPassCliCommand, config::ConfigCliCommand, contributor::ContributorCliCommand, device::DeviceCliCommand, exchange::ExchangeCliCommand, globalconfig::GlobalConfigCliCommand, - link::LinkCliCommand, location::LocationCliCommand, permission::PermissionCliCommand, - tenant::TenantCliCommand, user::UserCliCommand, + link::LinkCliCommand, location::LocationCliCommand, migrate::MigrateCliCommand, + permission::PermissionCliCommand, tenant::TenantCliCommand, user::UserCliCommand, }; use clap::{Args, Subcommand}; use clap_complete::Shell; @@ -69,6 +69,9 @@ pub enum Command { /// Sentinel admin commands #[command()] Sentinel(SentinelCliCommand), + /// Backfill link topologies and report Vpnv4 loopback gaps (RFC-18 migration) + #[command()] + Migrate(MigrateCliCommand), /// Export all data to files #[command()] Export(ExportCliCommand), diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs new file mode 100644 index 0000000000..0414a5b54e --- /dev/null +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -0,0 +1,130 @@ +use clap::Args; +use doublezero_cli::doublezerocommand::CliCommand; +use doublezero_sdk::commands::{ + device::list::ListDeviceCommand, + link::{list::ListLinkCommand, update::UpdateLinkCommand}, + topology::list::ListTopologyCommand, +}; +use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; +use solana_sdk::pubkey::Pubkey; +use std::{collections::HashSet, io::Write}; + +#[derive(Args, Debug)] +pub struct MigrateCliCommand { + /// Print what would be changed without submitting transactions + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +impl MigrateCliCommand { + pub fn execute(&self, client: &C, out: &mut W) -> eyre::Result<()> { + let program_id = client.get_program_id(); + + // Verify UNICAST-DEFAULT topology PDA exists on chain. + let (unicast_default_pda, _) = get_topology_pda(&program_id, "UNICAST-DEFAULT"); + client + .get_account(unicast_default_pda) + .map_err(|_| eyre::eyre!("UNICAST-DEFAULT topology PDA {unicast_default_pda} not found on chain — cannot proceed"))?; + + // ── Part 1: link topology backfill ─────────────────────────────────────── + + let links = client.list_link(ListLinkCommand)?; + let mut links_tagged = 0u32; + let mut links_skipped = 0u32; + + let mut link_entries: Vec<(Pubkey, _)> = links.into_iter().collect(); + link_entries.sort_by_key(|(pk, _)| pk.to_string()); + + for (pubkey, link) in &link_entries { + if link.link_topologies.is_empty() { + writeln!( + out, + " [link] {pubkey} ({}) — would tag UNICAST-DEFAULT", + link.code + )?; + if !self.dry_run { + let result = client.update_link(UpdateLinkCommand { + pubkey: *pubkey, + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + link_topologies: Some(vec![unicast_default_pda]), + unicast_drained: None, + }); + match result { + Ok(sig) => { + links_tagged += 1; + writeln!(out, " tagged: {sig}")?; + } + Err(e) => { + writeln!(out, " WARNING: failed to tag {pubkey}: {e}")?; + } + } + } else { + links_tagged += 1; + } + } else { + links_skipped += 1; + } + } + + // ── Part 2: Vpnv4 loopback gap reporting ───────────────────────────────── + + let topologies = client.list_topology(ListTopologyCommand)?; + let topology_pubkeys: HashSet = topologies.keys().copied().collect(); + + let devices = client.list_device(ListDeviceCommand)?; + let mut loopbacks_with_gaps = 0u32; + + let mut device_entries: Vec<(Pubkey, _)> = devices.into_iter().collect(); + device_entries.sort_by_key(|(pk, _)| pk.to_string()); + + for (device_pubkey, device) in &device_entries { + for iface in &device.interfaces { + let current = iface.into_current_version(); + if current.loopback_type != LoopbackType::Vpnv4 { + continue; + } + + let present: HashSet = current + .flex_algo_node_segments + .iter() + .map(|seg| seg.topology) + .collect(); + + let missing_count = topology_pubkeys.difference(&present).count(); + if missing_count > 0 { + loopbacks_with_gaps += 1; + writeln!( + out, + " [loopback] {device_pubkey} iface={} — missing {missing_count} topology entries; re-create topology with device accounts to backfill", + current.name + )?; + } + } + } + + // ── Summary ────────────────────────────────────────────────────────────── + + let dry_run_suffix = if self.dry_run { + " [DRY RUN — no changes made]" + } else { + "" + }; + writeln!( + out, + "\nMigration complete: {links_tagged} links tagged, {links_skipped} links skipped, {loopbacks_with_gaps} loopbacks with gaps{dry_run_suffix}" + )?; + + Ok(()) + } +} diff --git a/controlplane/doublezero-admin/src/cli/mod.rs b/controlplane/doublezero-admin/src/cli/mod.rs index 3985558bed..1fab197adc 100644 --- a/controlplane/doublezero-admin/src/cli/mod.rs +++ b/controlplane/doublezero-admin/src/cli/mod.rs @@ -7,6 +7,7 @@ pub mod exchange; pub mod globalconfig; pub mod link; pub mod location; +pub mod migrate; pub mod multicast; pub mod multicastgroup; pub mod permission; diff --git a/controlplane/doublezero-admin/src/main.rs b/controlplane/doublezero-admin/src/main.rs index 73f4986141..238d84f8c8 100644 --- a/controlplane/doublezero-admin/src/main.rs +++ b/controlplane/doublezero-admin/src/main.rs @@ -256,6 +256,7 @@ async fn main() -> eyre::Result<()> { } }, + Command::Migrate(args) => args.execute(&client, &mut handle), Command::Export(args) => args.execute(&client, &mut handle), Command::Keygen(args) => args.execute(&client, &mut handle), Command::Log(args) => args.execute(&dzclient, &mut handle), From a7de29085fa30ad648faa83f41a3a47a9772570b Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 11:56:29 -0500 Subject: [PATCH 18/54] =?UTF-8?q?cli:=20fix=20migrate=20command=20?= =?UTF-8?q?=E2=80=94=20unicast-default=20seed=20case,=20dry-run=20counter?= =?UTF-8?q?=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- controlplane/doublezero-admin/src/cli/migrate.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index 0414a5b54e..57ec0f9b2f 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -21,7 +21,7 @@ impl MigrateCliCommand { let program_id = client.get_program_id(); // Verify UNICAST-DEFAULT topology PDA exists on chain. - let (unicast_default_pda, _) = get_topology_pda(&program_id, "UNICAST-DEFAULT"); + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); client .get_account(unicast_default_pda) .map_err(|_| eyre::eyre!("UNICAST-DEFAULT topology PDA {unicast_default_pda} not found on chain — cannot proceed"))?; @@ -30,6 +30,7 @@ impl MigrateCliCommand { let links = client.list_link(ListLinkCommand)?; let mut links_tagged = 0u32; + let mut links_needing_tag = 0u32; let mut links_skipped = 0u32; let mut link_entries: Vec<(Pubkey, _)> = links.into_iter().collect(); @@ -37,6 +38,7 @@ impl MigrateCliCommand { for (pubkey, link) in &link_entries { if link.link_topologies.is_empty() { + links_needing_tag += 1; writeln!( out, " [link] {pubkey} ({}) — would tag UNICAST-DEFAULT", @@ -69,8 +71,6 @@ impl MigrateCliCommand { writeln!(out, " WARNING: failed to tag {pubkey}: {e}")?; } } - } else { - links_tagged += 1; } } else { links_skipped += 1; @@ -120,9 +120,14 @@ impl MigrateCliCommand { } else { "" }; + let tagged_summary = if self.dry_run { + format!("{links_needing_tag} link(s) would be tagged") + } else { + format!("{links_tagged} link(s) tagged") + }; writeln!( out, - "\nMigration complete: {links_tagged} links tagged, {links_skipped} links skipped, {loopbacks_with_gaps} loopbacks with gaps{dry_run_suffix}" + "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped, {loopbacks_with_gaps} loopback(s) with gaps{dry_run_suffix}" )?; Ok(()) From a320deb1aeb96898c0b64e93707f879727d45e12 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 12:20:26 -0500 Subject: [PATCH 19/54] cli: add AdminGroupBits to resource CLI type enum --- smartcontract/cli/src/resource/allocate.rs | 3 ++- smartcontract/cli/src/resource/deallocate.rs | 3 ++- smartcontract/cli/src/resource/mod.rs | 8 ++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/smartcontract/cli/src/resource/allocate.rs b/smartcontract/cli/src/resource/allocate.rs index 2e55d0d364..e5c87fcaaf 100644 --- a/smartcontract/cli/src/resource/allocate.rs +++ b/smartcontract/cli/src/resource/allocate.rs @@ -40,7 +40,8 @@ impl From for AllocateResourceCommand { | ResourceType::DzPrefixBlock => { IdOrIp::Ip(x.parse::().expect("Failed to parse IP address")) } - ResourceType::TunnelIds + ResourceType::AdminGroupBits + | ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => IdOrIp::Id(x.parse::().expect("Failed to parse ID")), diff --git a/smartcontract/cli/src/resource/deallocate.rs b/smartcontract/cli/src/resource/deallocate.rs index 21cc73f360..e0af19f55c 100644 --- a/smartcontract/cli/src/resource/deallocate.rs +++ b/smartcontract/cli/src/resource/deallocate.rs @@ -42,7 +42,8 @@ impl From for DeallocateResourceCommand { .parse::() .expect("Failed to parse IP address"), ), - ResourceType::TunnelIds + ResourceType::AdminGroupBits + | ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => { diff --git a/smartcontract/cli/src/resource/mod.rs b/smartcontract/cli/src/resource/mod.rs index dfddc61591..4b6008c4c6 100644 --- a/smartcontract/cli/src/resource/mod.rs +++ b/smartcontract/cli/src/resource/mod.rs @@ -11,6 +11,7 @@ pub mod verify; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum ResourceType { + AdminGroupBits, DeviceTunnelBlock, UserTunnelBlock, MulticastGroupBlock, @@ -28,6 +29,7 @@ pub fn resource_type_from( index: Option, ) -> SdkResourceType { match ext { + ResourceType::AdminGroupBits => SdkResourceType::AdminGroupBits, ResourceType::DeviceTunnelBlock => SdkResourceType::DeviceTunnelBlock, ResourceType::UserTunnelBlock => SdkResourceType::UserTunnelBlock, ResourceType::MulticastGroupBlock => SdkResourceType::MulticastGroupBlock, @@ -75,6 +77,12 @@ mod tests { use super::*; use solana_program::pubkey::Pubkey; + #[test] + fn test_admin_group_bits() { + let result = resource_type_from(ResourceType::AdminGroupBits, None, None); + assert_eq!(result, SdkResourceType::AdminGroupBits); + } + #[test] fn test_device_tunnel_block() { let result = resource_type_from(ResourceType::DeviceTunnelBlock, None, None); From ec0a0e739c9ed691ec72af787c87c211d0938928 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 13:57:52 -0500 Subject: [PATCH 20/54] smartcontract: create AdminGroupBits in global-config set Wire AdminGroupBits resource creation into process_set_globalconfig, following the same pattern as DeviceTunnelBlock, LinkIds, etc. The PDA is created on first initialization and a migration path handles existing deployments that predate RFC-18. Remove the separate create_admin_group_bits test helper and replace all call sites with a plain PDA derivation; update all SetGlobalConfig account lists to include admin_group_bits_pda as the 9th account. --- .../src/processors/globalconfig/set.rs | 32 +++ .../tests/accesspass_allow_multiple_ip.rs | 3 + .../tests/create_subscribe_user_test.rs | 12 + .../tests/device_test.rs | 6 + .../tests/device_update_location_test.rs | 3 + .../tests/exchange_setdevice.rs | 3 + .../tests/exchange_test.rs | 16 ++ .../tests/global_test.rs | 3 + .../tests/interface_test.rs | 3 + .../tests/link_dzx_test.rs | 3 + .../tests/link_wan_test.rs | 12 + .../tests/multicastgroup_subscribe_test.rs | 3 + .../tests/test_helpers.rs | 33 +-- .../tests/topology_test.rs | 221 ++++-------------- .../tests/user_migration.rs | 3 + .../tests/user_old_test.rs | 3 + .../tests/user_onchain_allocation_test.rs | 6 + .../tests/user_tests.rs | 9 + .../sdk/rs/src/commands/globalconfig/set.rs | 3 + 19 files changed, 174 insertions(+), 203 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs index 5ce66bd448..c4f41e6645 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs @@ -65,6 +65,7 @@ pub fn process_set_globalconfig( let segment_routing_ids_account = next_account_info(accounts_iter)?; let multicast_publisher_block_account = next_account_info(accounts_iter)?; let vrf_ids_account = next_account_info(accounts_iter)?; + let admin_group_bits_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; @@ -104,6 +105,8 @@ pub fn process_set_globalconfig( get_resource_extension_pda(program_id, ResourceType::MulticastGroupBlock); let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(program_id, ResourceType::MulticastPublisherBlock); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::AdminGroupBits); assert_eq!( device_tunnel_block_account.key, &device_tunnel_block_pda, @@ -125,6 +128,11 @@ pub fn process_set_globalconfig( "Invalid Multicast Publisher Block PubKey" ); + assert_eq!( + admin_group_bits_account.key, &admin_group_bits_pda, + "Invalid AdminGroupBits PubKey" + ); + let next_bgp_community = if let Some(val) = value.next_bgp_community { val } else if pda_account.try_borrow_data()?.is_empty() { @@ -230,6 +238,16 @@ pub fn process_set_globalconfig( accounts, ResourceType::VrfIds, )?; + + create_resource( + program_id, + admin_group_bits_account, + None, + pda_account, + payer_account, + accounts, + ResourceType::AdminGroupBits, + )?; } else { let old_data = GlobalConfig::try_from(pda_account)?; if old_data.device_tunnel_block != data.device_tunnel_block { @@ -266,6 +284,20 @@ pub fn process_set_globalconfig( ResourceType::MulticastPublisherBlock, )?; } + + // Create AdminGroupBits PDA if it doesn't exist yet (migration support for + // deployments that predate RFC-18). + if admin_group_bits_account.data_is_empty() { + create_resource( + program_id, + admin_group_bits_account, + None, + pda_account, + payer_account, + accounts, + ResourceType::AdminGroupBits, + )?; + } } #[cfg(test)] diff --git a/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs b/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs index fdffaa5bc6..f4d63858f8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs @@ -63,6 +63,8 @@ async fn test_accesspass_allow_multiple_ip() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_accesspass_allow_multiple_ip() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs index cabec169ec..a57b0bbe75 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs @@ -109,6 +109,8 @@ async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFi let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state execute_transaction( @@ -148,6 +150,7 @@ async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFi AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1548,6 +1551,8 @@ async fn test_create_subscribe_user_foundation_owner_override() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state (payer is automatically in foundation_allowlist) execute_transaction( @@ -1587,6 +1592,7 @@ async fn test_create_subscribe_user_foundation_owner_override() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1888,6 +1894,8 @@ async fn test_create_subscribe_user_sentinel_owner_override() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state execute_transaction( @@ -1944,6 +1952,7 @@ async fn test_create_subscribe_user_sentinel_owner_override() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -2244,6 +2253,8 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state with foundation payer execute_transaction( @@ -2283,6 +2294,7 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs index b825865975..0a2b9812e8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs @@ -75,6 +75,8 @@ async fn test_device() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -98,6 +100,7 @@ async fn test_device() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1058,6 +1061,8 @@ async fn setup_program_with_location_and_exchange( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1082,6 +1087,7 @@ async fn setup_program_with_location_and_exchange( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs index 133cc3d871..3ce9fd61ed 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs @@ -64,6 +64,8 @@ async fn device_update_location_test() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -87,6 +89,7 @@ async fn device_update_location_test() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs index 55e237f29a..1c16f6c51c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs @@ -57,6 +57,8 @@ async fn exchange_setdevice() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -80,6 +82,7 @@ async fn exchange_setdevice() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs index 7c5d1b576f..66ee1f458a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs @@ -55,6 +55,8 @@ async fn test_exchange() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -79,6 +81,7 @@ async fn test_exchange() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -283,6 +286,8 @@ async fn test_exchange_delete_from_suspended() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -307,6 +312,7 @@ async fn test_exchange_delete_from_suspended() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -409,6 +415,8 @@ async fn test_exchange_owner_and_foundation_can_update_status() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -432,6 +440,7 @@ async fn test_exchange_owner_and_foundation_can_update_status() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -550,6 +559,8 @@ async fn test_exchange_bgp_community_autoassignment() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); println!("Initializing global state..."); execute_transaction( @@ -589,6 +600,7 @@ async fn test_exchange_bgp_community_autoassignment() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -746,6 +758,7 @@ async fn test_exchange_bgp_community_autoassignment() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -857,6 +870,8 @@ async fn test_suspend_exchange_from_suspended_fails() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -896,6 +911,7 @@ async fn test_suspend_exchange_from_suspended_fails() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index 37bc558a36..cf4afef973 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -74,6 +74,8 @@ async fn test_doublezero_program() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -98,6 +100,7 @@ async fn test_doublezero_program() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index dea1f51aa9..9547a0b3e7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -70,6 +70,8 @@ async fn test_device_interfaces() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -93,6 +95,7 @@ async fn test_device_interfaces() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index b19b0befcf..6b0729371d 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -64,6 +64,8 @@ async fn test_dzx_link() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -88,6 +90,7 @@ async fn test_dzx_link() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index b862d1de07..21b6221f54 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -63,6 +63,8 @@ async fn test_wan_link() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_wan_link() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -910,6 +913,8 @@ async fn test_wan_link_rejects_cyoa_interface() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -934,6 +939,7 @@ async fn test_wan_link_rejects_cyoa_interface() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1480,6 +1486,8 @@ async fn test_cannot_set_cyoa_on_linked_interface() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1504,6 +1512,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1941,6 +1950,8 @@ async fn setup_link_env() -> ( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1965,6 +1976,7 @@ async fn setup_link_env() -> ( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs index f686bb161f..d6fe8c2dfe 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs @@ -91,6 +91,8 @@ async fn setup_fixture() -> TestFixture { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -115,6 +117,7 @@ async fn setup_fixture() -> TestFixture { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index 21298b33f9..f784d12bc7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -6,10 +6,7 @@ use doublezero_serviceability::{ get_globalconfig_pda, get_globalstate_pda, get_program_config_pda, get_resource_extension_pda, get_topology_pda, }, - processors::{ - globalconfig::set::SetGlobalConfigArgs, resource::create::ResourceCreateArgs, - topology::create::TopologyCreateArgs, - }, + processors::{globalconfig::set::SetGlobalConfigArgs, topology::create::TopologyCreateArgs}, resource::ResourceType, state::{ accountdata::AccountData, accounttype::AccountType, device::Device, @@ -505,6 +502,8 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -544,6 +543,7 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -558,37 +558,20 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, ) } -/// Create the AdminGroupBits resource extension and the "unicast-default" topology. +/// Create the "unicast-default" topology. /// Returns the PDA of the "unicast-default" topology. -/// Requires that global state + global config are already initialized. +/// Requires that global state + global config are already initialized (AdminGroupBits is +/// created by SetGlobalConfig). #[allow(dead_code)] pub async fn create_unicast_default_topology( banks_client: &mut BanksClient, program_id: Pubkey, globalstate_pubkey: Pubkey, - globalconfig_pubkey: Pubkey, + _globalconfig_pubkey: Pubkey, payer: &Keypair, ) -> Pubkey { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - - execute_transaction( - banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(admin_group_bits_pda, false), - AccountMeta::new(Pubkey::default(), false), - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], - payer, - ) - .await; let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index fcc12595dd..aa0fc443d7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -19,7 +19,6 @@ use doublezero_serviceability::{ exchange::create::ExchangeCreateArgs, link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, - resource::create::ResourceCreateArgs, topology::{ clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, }, @@ -43,37 +42,6 @@ use solana_sdk::{ mod test_helpers; use test_helpers::*; -/// Creates the AdminGroupBits resource extension. -/// Requires that global state + global config are already initialized. -async fn create_admin_group_bits( - banks_client: &mut BanksClient, - program_id: Pubkey, - globalstate_pubkey: Pubkey, - globalconfig_pubkey: Pubkey, - payer: &Keypair, -) -> Pubkey { - let (resource_pubkey, _, _) = - get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - execute_transaction( - banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(resource_pubkey, false), - AccountMeta::new(Pubkey::default(), false), - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], - payer, - ) - .await; - resource_pubkey -} - /// Helper that creates the topology using the standard account layout. async fn create_topology( banks_client: &mut BanksClient, @@ -117,32 +85,13 @@ async fn get_topology(banks_client: &mut BanksClient, pubkey: Pubkey) -> Topolog async fn test_admin_group_bits_create_and_pre_mark() { println!("[TEST] test_admin_group_bits_create_and_pre_mark"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + // AdminGroupBits is created automatically by SetGlobalConfig (via setup_program_with_globalconfig). + let (mut banks_client, _payer, program_id, _globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - let (resource_pubkey, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - // Create the AdminGroupBits resource extension - execute_transaction( - &mut banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateResource(ResourceCreateArgs { - resource_type: ResourceType::AdminGroupBits, - }), - vec![ - AccountMeta::new(resource_pubkey, false), - AccountMeta::new(Pubkey::default(), false), // associated_account (not used) - AccountMeta::new(globalstate_pubkey, false), - AccountMeta::new(globalconfig_pubkey, false), - ], - &payer, - ) - .await; - // Verify the account was created and has data let account = banks_client .get_account(resource_pubkey) @@ -213,17 +162,11 @@ fn test_flex_algo_node_segment_roundtrip() { async fn test_topology_create_bit_0_first() { println!("[TEST] test_topology_create_bit_0_first"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -251,17 +194,11 @@ async fn test_topology_create_bit_0_first() { async fn test_topology_create_second_skips_bit_1() { println!("[TEST] test_topology_create_second_skips_bit_1"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // First topology gets bit 0 create_topology( @@ -303,17 +240,11 @@ async fn test_topology_create_second_skips_bit_1() { async fn test_topology_create_non_foundation_rejected() { println!("[TEST] test_topology_create_non_foundation_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Use a keypair that is NOT in the foundation allowlist let non_foundation = Keypair::new(); @@ -362,17 +293,11 @@ async fn test_topology_create_non_foundation_rejected() { async fn test_topology_create_name_too_long_rejected() { println!("[TEST] test_topology_create_name_too_long_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // 33-char name exceeds MAX_TOPOLOGY_NAME_LEN=32 // We use a dummy pubkey for the topology PDA since the name validation fires @@ -417,17 +342,11 @@ async fn test_topology_create_name_too_long_rejected() { async fn test_topology_create_duplicate_rejected() { println!("[TEST] test_topology_create_duplicate_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // First creation succeeds create_topology( @@ -484,14 +403,8 @@ async fn test_topology_create_backfills_vpnv4_loopbacks() { let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); // Create AdminGroupBits and SegmentRoutingIds resources - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let (segment_routing_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); @@ -1176,17 +1089,11 @@ async fn assign_link_topology( async fn test_topology_delete_succeeds_when_no_links() { println!("[TEST] test_topology_delete_succeeds_when_no_links"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -1229,17 +1136,11 @@ async fn test_topology_delete_succeeds_when_no_links() { async fn test_topology_delete_fails_when_link_references_it() { println!("[TEST] test_topology_delete_fails_when_link_references_it"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -1313,17 +1214,11 @@ async fn test_topology_delete_fails_when_link_references_it() { async fn test_topology_delete_bit_not_reused() { println!("[TEST] test_topology_delete_bit_not_reused"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Create "topology-a" — gets bit 0 create_topology( @@ -1379,17 +1274,11 @@ async fn test_topology_delete_bit_not_reused() { async fn test_topology_clear_removes_from_links() { println!("[TEST] test_topology_clear_removes_from_links"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, @@ -1459,17 +1348,11 @@ async fn test_topology_clear_removes_from_links() { async fn test_topology_clear_is_idempotent() { println!("[TEST] test_topology_clear_is_idempotent"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let test_topology_pda = create_topology( &mut banks_client, @@ -1538,17 +1421,11 @@ async fn test_topology_clear_is_idempotent() { async fn test_topology_delete_non_foundation_rejected() { println!("[TEST] test_topology_delete_non_foundation_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Create topology with foundation payer create_topology( @@ -1599,17 +1476,11 @@ async fn test_topology_delete_non_foundation_rejected() { async fn test_topology_clear_non_foundation_rejected() { println!("[TEST] test_topology_clear_non_foundation_rejected"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Create topology with foundation payer create_topology( @@ -1842,17 +1713,11 @@ async fn test_link_unicast_drained_foundation_can_set_any_link() { async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { println!("[TEST] test_link_unicast_drained_orthogonal_to_status_and_topologies"); - let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; - let admin_group_bits_pda = create_admin_group_bits( - &mut banks_client, - program_id, - globalstate_pubkey, - globalconfig_pubkey, - &payer, - ) - .await; + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); let topology_pda = create_topology( &mut banks_client, diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs index b265b91ad5..2669f46239 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs @@ -59,6 +59,8 @@ async fn test_user_migration() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -83,6 +85,7 @@ async fn test_user_migration() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs index d2bd69ca1d..60ee4a144a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs @@ -63,6 +63,8 @@ async fn test_old_user() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_old_user() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs index 2bd2446220..f611677505 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs @@ -108,6 +108,8 @@ async fn setup_user_onchain_allocation_test( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -147,6 +149,7 @@ async fn setup_user_onchain_allocation_test( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1969,6 +1972,8 @@ async fn setup_user_infra_without_user( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -2008,6 +2013,7 @@ async fn setup_user_infra_without_user( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs index 6997f56f22..75debb3453 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs @@ -66,6 +66,8 @@ async fn test_user() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -90,6 +92,7 @@ async fn test_user() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -564,6 +567,8 @@ async fn test_user_ban_requires_pendingban() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -587,6 +592,7 @@ async fn test_user_ban_requires_pendingban() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1216,6 +1222,8 @@ async fn setup_activated_user() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -1239,6 +1247,7 @@ async fn setup_activated_user() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs index bd0885a756..52495758bf 100644 --- a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs +++ b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs @@ -46,6 +46,8 @@ impl SetGlobalConfigCommand { ); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); client.execute_transaction( DoubleZeroInstruction::SetGlobalConfig(set_config_args), @@ -59,6 +61,7 @@ impl SetGlobalConfigCommand { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], ) } From fe4036303e9254c0638eb904f8d7c8ca88fc96ec Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 1 Apr 2026 13:57:57 -0500 Subject: [PATCH 21/54] cli: remove AdminGroupBits from resource operator commands AdminGroupBits is created automatically by global-config set (not by doublezero resource create), bits are auto-allocated by CreateTopology, and bits are never deallocated per RFC-18. No valid operator CLI workflow exists for any of the create/allocate/deallocate paths. --- smartcontract/cli/src/resource/allocate.rs | 3 +-- smartcontract/cli/src/resource/deallocate.rs | 3 +-- smartcontract/cli/src/resource/mod.rs | 8 -------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/smartcontract/cli/src/resource/allocate.rs b/smartcontract/cli/src/resource/allocate.rs index e5c87fcaaf..2e55d0d364 100644 --- a/smartcontract/cli/src/resource/allocate.rs +++ b/smartcontract/cli/src/resource/allocate.rs @@ -40,8 +40,7 @@ impl From for AllocateResourceCommand { | ResourceType::DzPrefixBlock => { IdOrIp::Ip(x.parse::().expect("Failed to parse IP address")) } - ResourceType::AdminGroupBits - | ResourceType::TunnelIds + ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => IdOrIp::Id(x.parse::().expect("Failed to parse ID")), diff --git a/smartcontract/cli/src/resource/deallocate.rs b/smartcontract/cli/src/resource/deallocate.rs index e0af19f55c..21cc73f360 100644 --- a/smartcontract/cli/src/resource/deallocate.rs +++ b/smartcontract/cli/src/resource/deallocate.rs @@ -42,8 +42,7 @@ impl From for DeallocateResourceCommand { .parse::() .expect("Failed to parse IP address"), ), - ResourceType::AdminGroupBits - | ResourceType::TunnelIds + ResourceType::TunnelIds | ResourceType::LinkIds | ResourceType::SegmentRoutingIds | ResourceType::VrfIds => { diff --git a/smartcontract/cli/src/resource/mod.rs b/smartcontract/cli/src/resource/mod.rs index 4b6008c4c6..dfddc61591 100644 --- a/smartcontract/cli/src/resource/mod.rs +++ b/smartcontract/cli/src/resource/mod.rs @@ -11,7 +11,6 @@ pub mod verify; #[derive(Clone, Copy, Debug, ValueEnum)] pub enum ResourceType { - AdminGroupBits, DeviceTunnelBlock, UserTunnelBlock, MulticastGroupBlock, @@ -29,7 +28,6 @@ pub fn resource_type_from( index: Option, ) -> SdkResourceType { match ext { - ResourceType::AdminGroupBits => SdkResourceType::AdminGroupBits, ResourceType::DeviceTunnelBlock => SdkResourceType::DeviceTunnelBlock, ResourceType::UserTunnelBlock => SdkResourceType::UserTunnelBlock, ResourceType::MulticastGroupBlock => SdkResourceType::MulticastGroupBlock, @@ -77,12 +75,6 @@ mod tests { use super::*; use solana_program::pubkey::Pubkey; - #[test] - fn test_admin_group_bits() { - let result = resource_type_from(ResourceType::AdminGroupBits, None, None); - assert_eq!(result, SdkResourceType::AdminGroupBits); - } - #[test] fn test_device_tunnel_block() { let result = resource_type_from(ResourceType::DeviceTunnelBlock, None, None); From 92bf96c10dd840017fd7734b7a706fcffb936798 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 00:07:36 -0500 Subject: [PATCH 22/54] smartcontract: fix topology/clear missing payer account in SDK command and test --- .../src/processors/topology/clear.rs | 5 ++++- smartcontract/sdk/rs/src/commands/topology/clear.rs | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs index f7c5757f55..6c84f57a31 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -38,7 +38,10 @@ pub fn process_topology_clear( msg!("process_topology_clear(name={})", value.name); // Payer must be a signer - assert!(payer_account.is_signer, "Payer must be a signer"); + if !payer_account.is_signer { + msg!("TopologyClear: payer must be a signer"); + return Err(DoubleZeroError::Unauthorized.into()); + } // Authorization: foundation keys only let globalstate = GlobalState::try_from(globalstate_account)?; diff --git a/smartcontract/sdk/rs/src/commands/topology/clear.rs b/smartcontract/sdk/rs/src/commands/topology/clear.rs index 6e3c48b971..c2b57595b4 100644 --- a/smartcontract/sdk/rs/src/commands/topology/clear.rs +++ b/smartcontract/sdk/rs/src/commands/topology/clear.rs @@ -19,9 +19,12 @@ impl ClearTopologyCommand { let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let payer = client.get_payer(); + let mut accounts = vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), ]; for link_pk in &self.link_pubkeys { @@ -57,6 +60,7 @@ mod tests { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let payer = client.get_payer(); client .expect_execute_transaction() @@ -67,6 +71,7 @@ mod tests { predicate::eq(vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), ]), ) .returning(|_, _| Ok(Signature::new_unique())); @@ -86,6 +91,7 @@ mod tests { let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let payer = client.get_payer(); let link1 = Pubkey::new_unique(); let link2 = Pubkey::new_unique(); @@ -98,6 +104,7 @@ mod tests { predicate::eq(vec![ AccountMeta::new_readonly(topology_pda, false), AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), AccountMeta::new(link1, false), AccountMeta::new(link2, false), ]), From 57075db121ef183775eb1be1de89afa626131388 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 00:07:49 -0500 Subject: [PATCH 23/54] smartcontract: add BackfillTopology instruction for post-creation Vpnv4 loopback backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a standalone BackfillTopology onchain instruction (variant 109) that allocates FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks for an already-created topology. Idempotent — skips loopbacks that already have an entry for this topology. Also fixes a pre-existing bug where CreateIndex/DeleteIndex unpack arms used discriminants 104/105, colliding with CreateTopology/DeleteTopology — corrected to 107/108 per their enum variant comments. Wires up SDK command (BackfillTopologyCommand), CLI command (BackfillTopologyCliCommand under `doublezero link topology backfill`), and rewrites doublezero-admin migrate Part 2 to actually call BackfillTopology instead of just reporting gaps. Integration tests: success + idempotency, non-foundation rejected, nonexistent topology rejected. --- client/doublezero/src/cli/link.rs | 7 +- client/doublezero/src/main.rs | 1 + .../doublezero-admin/src/cli/migrate.rs | 88 +++-- smartcontract/cli/src/doublezerocommand.rs | 9 +- smartcontract/cli/src/topology/backfill.rs | 43 +++ smartcontract/cli/src/topology/mod.rs | 1 + .../src/entrypoint.rs | 7 +- .../src/instructions.rs | 21 +- .../src/processors/topology/backfill.rs | 146 +++++++ .../src/processors/topology/mod.rs | 1 + .../tests/topology_test.rs | 356 +++++++++++++++++- .../sdk/rs/src/commands/topology/backfill.rs | 138 +++++++ .../sdk/rs/src/commands/topology/mod.rs | 1 + 13 files changed, 779 insertions(+), 40 deletions(-) create mode 100644 smartcontract/cli/src/topology/backfill.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs create mode 100644 smartcontract/sdk/rs/src/commands/topology/backfill.rs diff --git a/client/doublezero/src/cli/link.rs b/client/doublezero/src/cli/link.rs index 8e898db2f4..826547cbb0 100644 --- a/client/doublezero/src/cli/link.rs +++ b/client/doublezero/src/cli/link.rs @@ -6,8 +6,9 @@ use doublezero_cli::{ wan_create::*, }, topology::{ - clear::ClearTopologyCliCommand, create::CreateTopologyCliCommand, - delete::DeleteTopologyCliCommand, list::ListTopologyCliCommand, + backfill::BackfillTopologyCliCommand, clear::ClearTopologyCliCommand, + create::CreateTopologyCliCommand, delete::DeleteTopologyCliCommand, + list::ListTopologyCliCommand, }, }; @@ -78,6 +79,8 @@ pub enum TopologyCommands { Delete(DeleteTopologyCliCommand), /// Clear a topology from links Clear(ClearTopologyCliCommand), + /// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks + Backfill(BackfillTopologyCliCommand), /// List all topologies List(ListTopologyCliCommand), } diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index d5437e422b..dbc4464886 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -236,6 +236,7 @@ async fn main() -> eyre::Result<()> { TopologyCommands::Create(args) => args.execute(&client, &mut handle), TopologyCommands::Delete(args) => args.execute(&client, &mut handle), TopologyCommands::Clear(args) => args.execute(&client, &mut handle), + TopologyCommands::Backfill(args) => args.execute(&client, &mut handle), TopologyCommands::List(args) => args.execute(&client, &mut handle), }, }, diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index 57ec0f9b2f..ac0de05cec 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -3,11 +3,11 @@ use doublezero_cli::doublezerocommand::CliCommand; use doublezero_sdk::commands::{ device::list::ListDeviceCommand, link::{list::ListLinkCommand, update::UpdateLinkCommand}, - topology::list::ListTopologyCommand, + topology::{backfill::BackfillTopologyCommand, list::ListTopologyCommand}, }; use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; use solana_sdk::pubkey::Pubkey; -use std::{collections::HashSet, io::Write}; +use std::io::Write; #[derive(Args, Debug)] pub struct MigrateCliCommand { @@ -77,38 +77,69 @@ impl MigrateCliCommand { } } - // ── Part 2: Vpnv4 loopback gap reporting ───────────────────────────────── + // ── Part 2: Vpnv4 loopback FlexAlgoNodeSegment backfill ───────────────── let topologies = client.list_topology(ListTopologyCommand)?; - let topology_pubkeys: HashSet = topologies.keys().copied().collect(); + let mut topology_entries: Vec<(Pubkey, _)> = topologies.into_iter().collect(); + topology_entries.sort_by_key(|(pk, _)| pk.to_string()); let devices = client.list_device(ListDeviceCommand)?; - let mut loopbacks_with_gaps = 0u32; - let mut device_entries: Vec<(Pubkey, _)> = devices.into_iter().collect(); device_entries.sort_by_key(|(pk, _)| pk.to_string()); - for (device_pubkey, device) in &device_entries { - for iface in &device.interfaces { - let current = iface.into_current_version(); - if current.loopback_type != LoopbackType::Vpnv4 { - continue; + // For each topology, find devices that have Vpnv4 loopbacks missing that + // topology's segment — then backfill in a single transaction per topology. + let mut topologies_backfilled = 0u32; + let mut topologies_skipped = 0u32; + + for (topology_pubkey, topology) in &topology_entries { + // Collect devices that have at least one Vpnv4 loopback missing this topology + let mut devices_needing_backfill: Vec = Vec::new(); + + for (device_pubkey, device) in &device_entries { + let needs_backfill = device.interfaces.iter().any(|iface| { + let current = iface.into_current_version(); + current.loopback_type == LoopbackType::Vpnv4 + && !current + .flex_algo_node_segments + .iter() + .any(|s| s.topology == *topology_pubkey) + }); + if needs_backfill { + devices_needing_backfill.push(*device_pubkey); } + } + + if devices_needing_backfill.is_empty() { + topologies_skipped += 1; + continue; + } - let present: HashSet = current - .flex_algo_node_segments - .iter() - .map(|seg| seg.topology) - .collect(); - - let missing_count = topology_pubkeys.difference(&present).count(); - if missing_count > 0 { - loopbacks_with_gaps += 1; - writeln!( - out, - " [loopback] {device_pubkey} iface={} — missing {missing_count} topology entries; re-create topology with device accounts to backfill", - current.name - )?; + topologies_backfilled += 1; + writeln!( + out, + " [topology] {} ({}) — {} device(s) need backfill", + topology.name, + topology_pubkey, + devices_needing_backfill.len() + )?; + + if !self.dry_run { + let result = client.backfill_topology(BackfillTopologyCommand { + name: topology.name.clone(), + device_pubkeys: devices_needing_backfill, + }); + match result { + Ok(sig) => { + writeln!(out, " backfilled: {sig}")?; + } + Err(e) => { + writeln!( + out, + " WARNING: failed to backfill topology {}: {e}", + topology.name + )?; + } } } } @@ -125,9 +156,14 @@ impl MigrateCliCommand { } else { format!("{links_tagged} link(s) tagged") }; + let loopback_summary = if self.dry_run { + format!("{topologies_backfilled} topology(s) would be backfilled") + } else { + format!("{topologies_backfilled} topology(s) backfilled") + }; writeln!( out, - "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped, {loopbacks_with_gaps} loopback(s) with gaps{dry_run_suffix}" + "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped; {loopback_summary}, {topologies_skipped} topology(s) already complete{dry_run_suffix}" )?; Ok(()) diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index 1b1eab3126..873260bd0c 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -100,8 +100,9 @@ use doublezero_sdk::{ update_payment_status::UpdatePaymentStatusCommand, }, topology::{ - clear::ClearTopologyCommand, create::CreateTopologyCommand, - delete::DeleteTopologyCommand, list::ListTopologyCommand, + backfill::BackfillTopologyCommand, clear::ClearTopologyCommand, + create::CreateTopologyCommand, delete::DeleteTopologyCommand, + list::ListTopologyCommand, }, user::{ create::CreateUserCommand, create_subscribe::CreateSubscribeUserCommand, @@ -345,6 +346,7 @@ pub trait CliCommand { fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)>; fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result; fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result; + fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result; fn list_topology( &self, cmd: ListTopologyCommand, @@ -821,6 +823,9 @@ impl CliCommand for CliCommandImpl<'_> { fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result { cmd.execute(self.client) } + fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } fn list_topology( &self, cmd: ListTopologyCommand, diff --git a/smartcontract/cli/src/topology/backfill.rs b/smartcontract/cli/src/topology/backfill.rs new file mode 100644 index 0000000000..6bb31a1ac0 --- /dev/null +++ b/smartcontract/cli/src/topology/backfill.rs @@ -0,0 +1,43 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::backfill::BackfillTopologyCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct BackfillTopologyCliCommand { + /// Name of the topology to backfill + #[arg(long)] + pub name: String, + /// Device account pubkeys to backfill (one or more) + #[arg(long = "device", value_name = "PUBKEY")] + pub device_pubkeys: Vec, +} + +impl BackfillTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + if self.device_pubkeys.is_empty() { + return Err(eyre::eyre!( + "at least one --device pubkey is required for backfill" + )); + } + + let sig = client.backfill_topology(BackfillTopologyCommand { + name: self.name.clone(), + device_pubkeys: self.device_pubkeys, + })?; + + writeln!( + out, + "Backfilled topology '{}'. Signature: {}", + self.name, sig + )?; + + Ok(()) + } +} diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs index 9c8c1e08a5..01fa6fd760 100644 --- a/smartcontract/cli/src/topology/mod.rs +++ b/smartcontract/cli/src/topology/mod.rs @@ -1,3 +1,4 @@ +pub mod backfill; pub mod clear; pub mod create; pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index bb65df6159..6c33335624 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -98,8 +98,8 @@ use crate::{ update::process_update_tenant, update_payment_status::process_update_payment_status, }, topology::{ - clear::process_topology_clear, create::process_topology_create, - delete::process_topology_delete, + backfill::process_topology_backfill, clear::process_topology_clear, + create::process_topology_create, delete::process_topology_delete, }, user::{ activate::process_activate_user, ban::process_ban_user, @@ -444,6 +444,9 @@ pub fn process_instruction( DoubleZeroInstruction::ClearTopology(value) => { process_topology_clear(program_id, accounts, &value)? } + DoubleZeroInstruction::BackfillTopology(value) => { + process_topology_backfill(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index d33d5aad44..ab9017bc73 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -80,7 +80,10 @@ use crate::processors::{ delete::TenantDeleteArgs, remove_administrator::TenantRemoveAdministratorArgs, update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, }, - topology::{clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs}, + topology::{ + backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, + delete::TopologyDeleteArgs, + }, user::{ activate::UserActivateArgs, ban::UserBanArgs, check_access_pass::CheckUserAccessPassArgs, closeaccount::UserCloseAccountArgs, create::UserCreateArgs, @@ -228,6 +231,7 @@ pub enum DoubleZeroInstruction { CreateTopology(TopologyCreateArgs), // variant 107 DeleteTopology(TopologyDeleteArgs), // variant 108 ClearTopology(TopologyClearArgs), // variant 109 + BackfillTopology(TopologyBackfillArgs), // variant 110 } impl DoubleZeroInstruction { @@ -365,6 +369,7 @@ impl DoubleZeroInstruction { 107 => Ok(Self::CreateTopology(TopologyCreateArgs::try_from(rest).unwrap())), 108 => Ok(Self::DeleteTopology(TopologyDeleteArgs::try_from(rest).unwrap())), 109 => Ok(Self::ClearTopology(TopologyClearArgs::try_from(rest).unwrap())), + 110 => Ok(Self::BackfillTopology(TopologyBackfillArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -503,9 +508,10 @@ impl DoubleZeroInstruction { Self::CreateIndex(_) => "CreateIndex".to_string(), // variant 104 Self::DeleteIndex(_) => "DeleteIndex".to_string(), // variant 105 Self::SetUserBGPStatus(_) => "SetUserBGPStatus".to_string(), // variant 106 - Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 - Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 - Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 + Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 110 } } @@ -636,9 +642,10 @@ impl DoubleZeroInstruction { Self::CreateIndex(args) => format!("{args:?}"), // variant 104 Self::DeleteIndex(args) => format!("{args:?}"), // variant 105 Self::SetUserBGPStatus(args) => format!("{args:?}"), // variant 106 - Self::CreateTopology(args) => format!("{args:?}"), // variant 107 - Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 - Self::ClearTopology(args) => format!("{args:?}"), // variant 109 + Self::CreateTopology(args) => format!("{args:?}"), // variant 107 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 + Self::ClearTopology(args) => format!("{args:?}"), // variant 109 + Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs new file mode 100644 index 0000000000..33452554b6 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs @@ -0,0 +1,146 @@ +use crate::{ + error::DoubleZeroError, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::resource::allocate_id, + resource::ResourceType, + serializer::try_acc_write, + state::{ + device::Device, + globalstate::GlobalState, + interface::{Interface, LoopbackType}, + topology::FlexAlgoNodeSegment, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyBackfillArgs { + pub name: String, +} + +/// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks for an +/// already-created topology. Idempotent — skips loopbacks that already have +/// an entry for this topology. +/// +/// Accounts layout: +/// [0] topology PDA (readonly — must already exist) +/// [1] segment_routing_ids (writable, ResourceExtension) +/// [2] globalstate (readonly) +/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [4+] Device accounts (writable) +pub fn process_topology_backfill( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyBackfillArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let segment_routing_ids_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_backfill(name={})", value.name); + + if !payer_account.is_signer { + msg!("TopologyBackfill: payer must be a signer"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyBackfill: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + if topology_account.key != &expected_pda { + msg!( + "TopologyBackfill: invalid topology PDA for name '{}'", + value.name + ); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + if topology_account.data_is_empty() { + msg!("TopologyBackfill: topology '{}' does not exist", value.name); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + // Validate SegmentRoutingIds account + let (expected_sr_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + if segment_routing_ids_account.key != &expected_sr_pda { + msg!("TopologyBackfill: invalid SegmentRoutingIds PDA"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + let topology_key = topology_account.key; + let mut backfilled_count: usize = 0; + let mut skipped_count: usize = 0; + + for device_account in accounts_iter { + if device_account.owner != program_id { + continue; + } + let mut device = match Device::try_from(&device_account.data.borrow()[..]) { + Ok(d) => d, + Err(_) => continue, + }; + let mut modified = false; + for iface in device.interfaces.iter_mut() { + let iface_v3 = iface.into_current_version(); + if iface_v3.loopback_type != LoopbackType::Vpnv4 { + continue; + } + // Skip if already has a segment for this topology (idempotent) + if iface_v3 + .flex_algo_node_segments + .iter() + .any(|s| &s.topology == topology_key) + { + skipped_count += 1; + continue; + } + let node_segment_idx = allocate_id(segment_routing_ids_account)?; + match iface { + Interface::V3(ref mut v3) => { + v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + } + _ => { + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + *iface = Interface::V3(upgraded); + } + } + modified = true; + backfilled_count += 1; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + + msg!( + "TopologyBackfill: '{}' — {} loopback(s) backfilled, {} already had segment", + value.name, + backfilled_count, + skipped_count + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs index 52a0eb0975..b45b525a64 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs @@ -1,3 +1,4 @@ +pub mod backfill; pub mod clear; pub mod create; pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index aa0fc443d7..4c6a69d13e 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -20,7 +20,8 @@ use doublezero_serviceability::{ link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, topology::{ - clear::TopologyClearArgs, create::TopologyCreateArgs, delete::TopologyDeleteArgs, + backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, + delete::TopologyDeleteArgs, }, }, resource::{IdOrIp, ResourceType}, @@ -1536,6 +1537,359 @@ async fn test_topology_clear_non_foundation_rejected() { println!("[PASS] test_topology_clear_non_foundation_rejected"); } +// ============================================================================ +// BackfillTopology tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_backfill_populates_vpnv4_loopbacks() { + println!("[TEST] test_topology_backfill_populates_vpnv4_loopbacks"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "loopback0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Create topology WITHOUT passing device accounts — no backfill at create time + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Verify: device has 0 flex_algo_node_segments before backfill + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 0, + "Expected no segments before BackfillTopology" + ); + + // Step 8: Call BackfillTopology instruction + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![AccountMeta::new(device_pubkey, false)]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: loopback now has 1 segment pointing to the topology + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after BackfillTopology" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the backfilled topology" + ); + + // Step 9: Call BackfillTopology again — idempotent, no duplicate segment + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx2 = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx2.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx2).await.unwrap(); + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after second backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Idempotent: BackfillTopology must not add a duplicate segment" + ); + + println!("[PASS] test_topology_backfill_populates_vpnv4_loopbacks"); +} + +#[tokio::test] +async fn test_topology_backfill_non_foundation_rejected() { + println!("[TEST] test_topology_backfill_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_backfill_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_backfill_nonexistent_topology_rejected() { + println!("[TEST] test_topology_backfill_nonexistent_topology_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Use a topology PDA that has never been created + let (nonexistent_topology_pda, _) = get_topology_pda(&program_id, "does-not-exist"); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "does-not-exist".to_string(), + }), + vec![ + AccountMeta::new_readonly(nonexistent_topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // DoubleZeroError::InvalidArgument = Custom(65) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(65), + ))) => {} + _ => panic!( + "Expected InvalidArgument error (Custom(65)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_backfill_nonexistent_topology_rejected"); +} + // ============================================================================ // unicast_drained tests // ============================================================================ diff --git a/smartcontract/sdk/rs/src/commands/topology/backfill.rs b/smartcontract/sdk/rs/src/commands/topology/backfill.rs new file mode 100644 index 0000000000..7342ba2cc3 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/backfill.rs @@ -0,0 +1,138 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::topology::backfill::TopologyBackfillArgs, + resource::ResourceType, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct BackfillTopologyCommand { + pub name: String, + pub device_pubkeys: Vec, +} + +impl BackfillTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + + let payer = client.get_payer(); + + let mut accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]; + + for device_pk in &self.device_pubkeys { + accounts.push(AccountMeta::new(*device_pk, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: self.name.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::backfill::BackfillTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, + processors::topology::backfill::TopologyBackfillArgs, + resource::ResourceType, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_topology_backfill_no_devices() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + let (sr_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + let payer = client.get_payer(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::BackfillTopology( + TopologyBackfillArgs { + name: "unicast-default".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(sr_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = BackfillTopologyCommand { + name: "unicast-default".to_string(), + device_pubkeys: vec![], + } + .execute(&client); + + assert!(res.is_ok()); + } + + #[test] + fn test_commands_topology_backfill_with_devices() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "algo128"); + let (sr_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + let payer = client.get_payer(); + let device1 = Pubkey::new_unique(); + let device2 = Pubkey::new_unique(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::BackfillTopology( + TopologyBackfillArgs { + name: "algo128".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(sr_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + AccountMeta::new(device1, false), + AccountMeta::new(device2, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = BackfillTopologyCommand { + name: "algo128".to_string(), + device_pubkeys: vec![device1, device2], + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/mod.rs b/smartcontract/sdk/rs/src/commands/topology/mod.rs index 9c8c1e08a5..01fa6fd760 100644 --- a/smartcontract/sdk/rs/src/commands/topology/mod.rs +++ b/smartcontract/sdk/rs/src/commands/topology/mod.rs @@ -1,3 +1,4 @@ +pub mod backfill; pub mod clear; pub mod create; pub mod delete; From 12630d7861c53c0a1f205fd120fc9bbe82efae57 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 00:53:45 -0500 Subject: [PATCH 24/54] cli: validate topology name length before PDA derivation --- smartcontract/cli/src/topology/create.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs index b89b7af449..febdaeedc0 100644 --- a/smartcontract/cli/src/topology/create.rs +++ b/smartcontract/cli/src/topology/create.rs @@ -30,6 +30,10 @@ fn parse_constraint(s: &str) -> Result { impl CreateTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + if self.name.len() > 32 { + eyre::bail!("topology name must be 32 characters or fewer (got {})", self.name.len()); + } + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; let (_, topology_pda) = client.create_topology(CreateTopologyCommand { @@ -97,4 +101,17 @@ mod tests { fn test_parse_constraint_invalid() { assert!(parse_constraint("unknown").is_err()); } + + #[test] + fn test_create_topology_name_too_long() { + let cmd = CreateTopologyCliCommand { + name: "a".repeat(33), + constraint: TopologyConstraint::IncludeAny, + }; + let mock = MockCliCommand::new(); + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("32 characters or fewer")); + } } From c7749b6af57510f087aba0a5a5e67843ef71c141 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 08:30:58 -0500 Subject: [PATCH 25/54] e2e: add doublezero-admin to manager container --- e2e/docker/manager/Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/docker/manager/Dockerfile b/e2e/docker/manager/Dockerfile index a304ec5d9f..89c89cf5e6 100644 --- a/e2e/docker/manager/Dockerfile +++ b/e2e/docker/manager/Dockerfile @@ -13,6 +13,7 @@ COPY --from=base /doublezero/bin/doublezero_serviceability.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_telemetry.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_geolocation.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero-geolocation /doublezero/bin/. +COPY --from=base /doublezero/bin/doublezero-admin /doublezero/bin/. COPY --from=base /usr/local/bin/solana /usr/local/bin/. COPY --from=base /usr/local/bin/solana-keygen /usr/local/bin/. From 6177a0ee1871a1121841eff3b1c1b34313127634 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 08:45:37 -0500 Subject: [PATCH 26/54] sdk: regenerate fixtures with RFC-18 fields; enable flex_algo_node_segments reading - link fixture: add link_topologies (1 entry) and unicast_drained=true - tenant fixture: add include_topologies (1 entry) - new topology_info fixture for TopologyInfo account (account_type=16) - Python/TypeScript Interface: add V3 deserialization with flex_algo_node_segments; bump CURRENT_INTERFACE_VERSION to 3 --- .../python/serviceability/state.py | 23 ++++++++- .../fixtures/generate-fixtures/src/main.rs | 46 ++++++++++++++++-- sdk/serviceability/testdata/fixtures/link.bin | Bin 230 -> 267 bytes .../testdata/fixtures/link.json | 15 ++++++ .../testdata/fixtures/tenant.bin | Bin 136 -> 172 bytes .../testdata/fixtures/tenant.json | 10 ++++ .../testdata/fixtures/topology_info.bin | Bin 0 -> 56 bytes .../testdata/fixtures/topology_info.json | 41 ++++++++++++++++ .../typescript/serviceability/state.ts | 27 +++++++++- 9 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 sdk/serviceability/testdata/fixtures/topology_info.bin create mode 100644 sdk/serviceability/testdata/fixtures/topology_info.json diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 1a61b961d9..fa15c6e0b7 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -400,7 +400,7 @@ def __str__(self) -> str: # Account dataclasses # --------------------------------------------------------------------------- -CURRENT_INTERFACE_VERSION = 2 +CURRENT_INTERFACE_VERSION = 3 @dataclass @@ -451,6 +451,27 @@ def from_reader(cls, r: IncrementalReader) -> 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 == 2: # 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() + 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 diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 2dac3cece6..1f31a136f0 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -87,6 +87,7 @@ fn main() { generate_access_pass(&fixtures_dir); generate_access_pass_validator(&fixtures_dir); generate_tenant(&fixtures_dir); + generate_topology(&fixtures_dir); generate_resource_extension_id(&fixtures_dir); generate_resource_extension_ip(&fixtures_dir); @@ -410,6 +411,7 @@ fn generate_link(dir: &Path) { let side_a_pk = pubkey_from_byte(0x51); let side_z_pk = pubkey_from_byte(0x52); let contributor_pk = pubkey_from_byte(0x53); + let topology_pk = pubkey_from_byte(0x54); let val = Link { account_type: AccountType::Link, @@ -433,8 +435,8 @@ fn generate_link(dir: &Path) { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, - link_topologies: Vec::new(), - unicast_drained: false, + link_topologies: vec![topology_pk], + unicast_drained: true, }; let data = borsh::to_vec(&val).unwrap(); @@ -464,6 +466,9 @@ fn generate_link(dir: &Path) { FieldValue { name: "DelayOverrideNs".into(), value: "0".into(), typ: "u64".into() }, FieldValue { name: "LinkHealth".into(), value: "2".into(), typ: "u8".into() }, FieldValue { name: "DesiredStatus".into(), value: "1".into(), typ: "u8".into() }, + FieldValue { name: "LinkTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "LinkTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, + FieldValue { name: "UnicastDrained".into(), value: "true".into(), typ: "bool".into() }, ], }; @@ -747,6 +752,7 @@ fn generate_tenant(dir: &Path) { let owner = pubkey_from_byte(0xD0); let admin_pk = pubkey_from_byte(0xD1); let token_account = pubkey_from_byte(0xD2); + let topology_pk = pubkey_from_byte(0xD3); let val = Tenant { account_type: AccountType::Tenant, @@ -761,7 +767,7 @@ fn generate_tenant(dir: &Path) { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), - include_topologies: vec![], + include_topologies: vec![topology_pk], }; let data = borsh::to_vec(&val).unwrap(); @@ -785,12 +791,46 @@ fn generate_tenant(dir: &Path) { FieldValue { name: "BillingDiscriminant".into(), value: "0".into(), typ: "u8".into() }, FieldValue { name: "BillingRate".into(), value: "0".into(), typ: "u64".into() }, FieldValue { name: "BillingLastDeductionDzEpoch".into(), value: "0".into(), typ: "u64".into() }, + FieldValue { name: "IncludeTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "IncludeTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, ], }; write_fixture(dir, "tenant", &data, &meta); } +fn generate_topology(dir: &Path) { + let owner = pubkey_from_byte(0xE0); + + let val = doublezero_serviceability::state::topology::TopologyInfo { + account_type: AccountType::Topology, + owner, + bump_seed: 250, + name: "unicast-default".into(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }; + + let data = borsh::to_vec(&val).unwrap(); + + let meta = FixtureMeta { + name: "TopologyInfo".into(), + account_type: 16, + fields: vec![ + FieldValue { name: "AccountType".into(), value: "16".into(), typ: "u8".into() }, + FieldValue { name: "Owner".into(), value: pubkey_bs58(&owner), typ: "pubkey".into() }, + FieldValue { name: "BumpSeed".into(), value: "250".into(), typ: "u8".into() }, + FieldValue { name: "Name".into(), value: "unicast-default".into(), typ: "string".into() }, + FieldValue { name: "AdminGroupBit".into(), value: "0".into(), typ: "u8".into() }, + FieldValue { name: "FlexAlgoNumber".into(), value: "128".into(), typ: "u8".into() }, + FieldValue { name: "Constraint".into(), value: "0".into(), typ: "u8".into() }, + ], + }; + + write_fixture(dir, "topology_info", &data, &meta); +} + /// ResourceExtension uses a fixed binary layout with bitmap at offset 88, /// so we manually construct the bytes rather than using borsh::to_vec. const RESOURCE_EXTENSION_BITMAP_OFFSET: usize = 88; diff --git a/sdk/serviceability/testdata/fixtures/link.bin b/sdk/serviceability/testdata/fixtures/link.bin index e988d5d648ce75f60f80587f4d43b35575db748e..3390047b45962a9eb8dded18d1b435ee3c367454 100644 GIT binary patch delta 44 XcmaFH*v&NInJOa#149T9;{%KUi2(y< delta 6 NcmeBXdd4{682||L0_y+( diff --git a/sdk/serviceability/testdata/fixtures/link.json b/sdk/serviceability/testdata/fixtures/link.json index b1f786b209..b5ca99ef35 100644 --- a/sdk/serviceability/testdata/fixtures/link.json +++ b/sdk/serviceability/testdata/fixtures/link.json @@ -106,6 +106,21 @@ "name": "DesiredStatus", "value": "1", "typ": "u8" + }, + { + "name": "LinkTopologiesLen", + "value": "1", + "typ": "u32" + }, + { + "name": "LinkTopologies0", + "value": "6euFJbx65EayK76qjPNWCqcCjpTkJoZA3rubHP4bYaXy", + "typ": "pubkey" + }, + { + "name": "UnicastDrained", + "value": "true", + "typ": "bool" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/tenant.bin b/sdk/serviceability/testdata/fixtures/tenant.bin index bfc41f6d8c32392e0110bb8b23c4e0b9baa30e5e..0748273f3c3b1303a66bdad6b116bdc7868dadad 100644 GIT binary patch delta 23 dcmeBRT*Ek_gO!nif#LGRM3IRFtU$!b002-k20H)% delta 6 NcmZ3(*uglV0{{o!0#5({ diff --git a/sdk/serviceability/testdata/fixtures/tenant.json b/sdk/serviceability/testdata/fixtures/tenant.json index 83ea1f76b8..a8fdcae48e 100644 --- a/sdk/serviceability/testdata/fixtures/tenant.json +++ b/sdk/serviceability/testdata/fixtures/tenant.json @@ -76,6 +76,16 @@ "name": "BillingLastDeductionDzEpoch", "value": "0", "typ": "u64" + }, + { + "name": "IncludeTopologiesLen", + "value": "1", + "typ": "u32" + }, + { + "name": "IncludeTopologies0", + "value": "FCf2QW6oaRTeBxNaLUCCuXGEc4DdyTVJD25ND9Tz5kVm", + "typ": "pubkey" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/topology_info.bin b/sdk/serviceability/testdata/fixtures/topology_info.bin new file mode 100644 index 0000000000000000000000000000000000000000..9f4db1e1b0de9a082d139550ebc3cab3c5a787a9 GIT binary patch literal 56 icmWf5z Date: Thu, 2 Apr 2026 10:14:39 -0500 Subject: [PATCH 27/54] sdk: fix InterfaceV3 deserialization in Go, Python, and TypeScript SDKs --- .../python/serviceability/state.py | 30 ++++---------- .../typescript/serviceability/state.ts | 39 +++++++------------ .../sdk/go/serviceability/deserialize.go | 21 ++++++++++ smartcontract/sdk/go/serviceability/state.go | 2 +- 4 files changed, 43 insertions(+), 49 deletions(-) diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index fa15c6e0b7..7ea0417c3f 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -436,7 +436,7 @@ def from_reader(cls, r: IncrementalReader) -> 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): # V2 or V3 iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() iface.interface_type = InterfaceType(r.read_u8()) @@ -451,27 +451,13 @@ def from_reader(cls, r: IncrementalReader) -> 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 == 2: # 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() - 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 == 2: # V3 + 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 diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 01d7e93058..184ff6c1e8 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -524,8 +524,8 @@ 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) { + // V2 or V3 iface.status = r.readU8(); iface.name = r.readString(); iface.interfaceType = r.readU8(); @@ -540,31 +540,18 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - } else if (iface.version === 2) { - // V3 - iface.status = r.readU8(); - iface.name = r.readString(); - iface.interfaceType = r.readU8(); - iface.interfaceCyoa = r.readU8(); - iface.interfaceDia = r.readU8(); - iface.loopbackType = r.readU8(); - iface.bandwidth = r.readU64(); - iface.cir = r.readU64(); - iface.mtu = r.readU16(); - iface.routingMode = r.readU8(); - iface.vlanId = r.readU16(); - 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++) { - flexAlgoNodeSegments.push({ - topology: readPubkey(r), - nodeSegmentIdx: r.readU16(), - }); + if (iface.version === 2) { + // V3 + const segCount = r.readU32(); + const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; + for (let i = 0; i < segCount; i++) { + flexAlgoNodeSegments.push({ + topology: readPubkey(r), + nodeSegmentIdx: r.readU16(), + }); + } + iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } - iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } return iface; diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index ed0ee87bfe..512926e5c9 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -93,6 +93,8 @@ func DeserializeInterface(reader *ByteReader, iface *Interface) { DeserializeInterfaceV1(reader, iface) case 1: // version 2 DeserializeInterfaceV2(reader, iface) + case 2: // version 3 + DeserializeInterfaceV3(reader, iface) } } @@ -124,6 +126,25 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.UserTunnelEndpoint = (reader.ReadU8() != 0) } +func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { + iface.Status = InterfaceStatus(reader.ReadU8()) + iface.Name = reader.ReadString() + iface.InterfaceType = InterfaceType(reader.ReadU8()) + iface.InterfaceCYOA = InterfaceCYOA(reader.ReadU8()) + iface.InterfaceDIA = InterfaceDIA(reader.ReadU8()) + loopbackTypeByte := reader.ReadU8() + iface.LoopbackType = LoopbackType(loopbackTypeByte) + 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) + iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() +} + func DeserializeDevice(reader *ByteReader, dev *Device) { dev.AccountType = AccountType(reader.ReadU8()) dev.Owner = reader.ReadPubkey() diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 4329b3a852..0dbccf55a2 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -425,7 +425,7 @@ func (i Interface) MarshalJSON() ([]byte, error) { return json.Marshal(jsonIface) } -const CurrentInterfaceVersion = 2 +const CurrentInterfaceVersion = 3 type Device struct { AccountType AccountType From 1170ccb05b065744f5fc5629c28e9aed62b19923 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 13:10:23 -0500 Subject: [PATCH 28/54] sdk: fix duplicate payer in execute_transaction_inner when payer already in accounts topology clear --links was broken: execute_transaction_inner always appended payer at the end, but clear.rs also included payer explicitly at [2]. Solana deduplicates by removing the first occurrence, causing the link account to shift into payer's position. The signer check then fails. Fix: skip appending payer if it is already present in the accounts list. Incidentally formats cli/src/topology/create.rs (nightly rustfmt). --- smartcontract/cli/src/topology/create.rs | 10 ++++-- .../sdk/go/serviceability/bytereader.go | 13 +++++++ smartcontract/sdk/go/serviceability/state.go | 36 +++++++++++-------- smartcontract/sdk/rs/src/client.rs | 16 ++++----- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs index febdaeedc0..0fb83a2fec 100644 --- a/smartcontract/cli/src/topology/create.rs +++ b/smartcontract/cli/src/topology/create.rs @@ -31,7 +31,10 @@ fn parse_constraint(s: &str) -> Result { impl CreateTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { if self.name.len() > 32 { - eyre::bail!("topology name must be 32 characters or fewer (got {})", self.name.len()); + eyre::bail!( + "topology name must be 32 characters or fewer (got {})", + self.name.len() + ); } client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; @@ -112,6 +115,9 @@ mod tests { let mut out = Cursor::new(Vec::new()); let result = cmd.execute(&mock, &mut out); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("32 characters or fewer")); + assert!(result + .unwrap_err() + .to_string() + .contains("32 characters or fewer")); } } diff --git a/smartcontract/sdk/go/serviceability/bytereader.go b/smartcontract/sdk/go/serviceability/bytereader.go index fa3fd479c4..441b36b7e3 100644 --- a/smartcontract/sdk/go/serviceability/bytereader.go +++ b/smartcontract/sdk/go/serviceability/bytereader.go @@ -180,3 +180,16 @@ func (br *ByteReader) Skip(n int) { br.offset = len(br.data) } } + +func (br *ByteReader) ReadFlexAlgoNodeSegmentSlice() []FlexAlgoNodeSegment { + length := br.ReadU32() + if length == 0 || (uint64(length)*34) > uint64(br.Remaining()) { + return nil + } + result := make([]FlexAlgoNodeSegment, length) + for i := uint32(0); i < length; i++ { + result[i].Topology = br.ReadPubkey() + result[i].NodeSegmentIdx = br.ReadU16() + } + return result +} diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 0dbccf55a2..67eddc17ed 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -385,22 +385,28 @@ func (l RoutingMode) MarshalJSON() ([]byte, error) { return json.Marshal(l.String()) } +type FlexAlgoNodeSegment struct { + Topology [32]uint8 + NodeSegmentIdx uint16 +} + type Interface struct { - Version uint8 - Status InterfaceStatus - Name string - InterfaceType InterfaceType - InterfaceCYOA InterfaceCYOA - InterfaceDIA InterfaceDIA - LoopbackType LoopbackType - Bandwidth uint64 - Cir uint64 - Mtu uint16 - RoutingMode RoutingMode - VlanId uint16 - IpNet [5]uint8 - NodeSegmentIdx uint16 - UserTunnelEndpoint bool + Version uint8 + Status InterfaceStatus + Name string + InterfaceType InterfaceType + InterfaceCYOA InterfaceCYOA + InterfaceDIA InterfaceDIA + LoopbackType LoopbackType + Bandwidth uint64 + Cir uint64 + Mtu uint16 + RoutingMode RoutingMode + VlanId uint16 + IpNet [5]uint8 + NodeSegmentIdx uint16 + UserTunnelEndpoint bool + FlexAlgoNodeSegments []FlexAlgoNodeSegment } func (i Interface) MarshalJSON() ([]byte, error) { diff --git a/smartcontract/sdk/rs/src/client.rs b/smartcontract/sdk/rs/src/client.rs index a648f4af09..d3aff79af7 100644 --- a/smartcontract/sdk/rs/src/client.rs +++ b/smartcontract/sdk/rs/src/client.rs @@ -144,18 +144,18 @@ impl DZClient { .ok_or_eyre("No default signer found, run \"doublezero keygen\" to create a new one")?; let data = instruction.pack(); + let payer_pubkey = payer.pubkey(); + let mut all_accounts = accounts; + if !all_accounts.iter().any(|a| a.pubkey == payer_pubkey) { + all_accounts.push(AccountMeta::new(payer_pubkey, true)); + } + all_accounts.push(AccountMeta::new(program::id(), false)); + let mut transaction = Transaction::new_with_payer( &[Instruction::new_with_bytes( self.program_id, &data, - [ - accounts, - vec![ - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new(program::id(), false), - ], - ] - .concat(), + all_accounts, )], Some(&payer.pubkey()), ); From 6802647704e18fe192cef76e519504047a3fb8e6 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 14:30:20 -0500 Subject: [PATCH 29/54] sdk/serviceability: add FlexAlgoNodeSegment type to Python and TypeScript SDKs Cherry-pick added V3 deserialization logic but not the type declarations. Add FlexAlgoNodeSegment dataclass/interface and flexAlgoNodeSegments field to Interface/DeviceInterface in both SDKs. --- sdk/serviceability/python/serviceability/state.py | 7 +++++++ sdk/serviceability/typescript/serviceability/state.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 7ea0417c3f..8868f1e9be 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -403,6 +403,12 @@ def __str__(self) -> str: CURRENT_INTERFACE_VERSION = 3 +@dataclass +class FlexAlgoNodeSegment: + topology: bytes = b"\x00" * 32 + node_segment_idx: int = 0 + + @dataclass class Interface: version: int = 0 @@ -420,6 +426,7 @@ class Interface: ip_net: bytes = b"\x00" * 5 node_segment_idx: int = 0 user_tunnel_endpoint: bool = False + flex_algo_node_segments: list["FlexAlgoNodeSegment"] = field(default_factory=list) @classmethod def from_reader(cls, r: IncrementalReader) -> Interface: diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 184ff6c1e8..1b417a1cd2 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -470,6 +470,11 @@ export function deserializeExchange(data: Uint8Array): Exchange { // Interface (versioned, embedded in Device) // --------------------------------------------------------------------------- +export interface FlexAlgoNodeSegment { + topology: PublicKey; + nodeSegmentIdx: number; +} + export interface DeviceInterface { version: number; status: number; @@ -486,6 +491,7 @@ export interface DeviceInterface { ipNet: Uint8Array; nodeSegmentIdx: number; userTunnelEndpoint: boolean; + flexAlgoNodeSegments?: FlexAlgoNodeSegment[]; } const CURRENT_INTERFACE_VERSION = 3; @@ -507,6 +513,7 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { ipNet: new Uint8Array(5), nodeSegmentIdx: 0, userTunnelEndpoint: false, + flexAlgoNodeSegments: [], }; iface.version = r.readU8(); From 60c1daa29bc90f4379dabd68af9e7694b03cd70e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 14:30:31 -0500 Subject: [PATCH 30/54] smartcontract: rustfmt formatting fixes in instructions.rs --- .../doublezero-serviceability/src/instructions.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index ab9017bc73..944ffde61b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -508,9 +508,9 @@ impl DoubleZeroInstruction { Self::CreateIndex(_) => "CreateIndex".to_string(), // variant 104 Self::DeleteIndex(_) => "DeleteIndex".to_string(), // variant 105 Self::SetUserBGPStatus(_) => "SetUserBGPStatus".to_string(), // variant 106 - Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 - Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 - Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 110 } } @@ -642,10 +642,10 @@ impl DoubleZeroInstruction { Self::CreateIndex(args) => format!("{args:?}"), // variant 104 Self::DeleteIndex(args) => format!("{args:?}"), // variant 105 Self::SetUserBGPStatus(args) => format!("{args:?}"), // variant 106 - Self::CreateTopology(args) => format!("{args:?}"), // variant 107 - Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 - Self::ClearTopology(args) => format!("{args:?}"), // variant 109 - Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 + Self::CreateTopology(args) => format!("{args:?}"), // variant 107 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 + Self::ClearTopology(args) => format!("{args:?}"), // variant 109 + Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 } } } From 86ce831d0349336cfefef4dfd52c35284d2f4293 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 2 Apr 2026 15:55:49 -0500 Subject: [PATCH 31/54] controlplane/controller: fix Interface zero-value comparison after slice field addition --- controlplane/controller/internal/controller/models.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controlplane/controller/internal/controller/models.go b/controlplane/controller/internal/controller/models.go index 2c8a0add5b..dcf970c675 100644 --- a/controlplane/controller/internal/controller/models.go +++ b/controlplane/controller/internal/controller/models.go @@ -49,7 +49,7 @@ type Interface struct { // toInterface validates onchain data for a serviceability interface and converts it to a controller interface. func toInterface(iface serviceability.Interface) (Interface, error) { - if iface == (serviceability.Interface{}) { + if iface.IpNet == ([5]byte{}) && iface.Name == "" { return Interface{}, errors.New("serviceability interface cannot be nil") } From 1335a0a7eba279e062bcc38952c1fbe703bad744 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:12:11 -0500 Subject: [PATCH 32/54] smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18 --- activator/src/process/link.rs | 10 ++-- client/doublezero/src/dzd_latency.rs | 2 +- .../doublezero-admin/src/cli/migrate.rs | 16 +++++- controlplane/doublezero-admin/src/main.rs | 4 +- .../python/serviceability/state.py | 54 ++++++++++++++++-- .../testdata/fixtures/device.bin | Bin 311 -> 315 bytes .../fixtures/generate-fixtures/src/main.rs | 5 +- .../testdata/fixtures/link.json | 6 +- .../fixtures/generate-fixtures/Cargo.lock | 8 +++ smartcontract/cli/src/link/accept.rs | 4 +- smartcontract/cli/src/link/delete.rs | 4 +- smartcontract/cli/src/link/dzx_create.rs | 36 ++++++------ smartcontract/cli/src/link/get.rs | 10 ++-- smartcontract/cli/src/link/latency.rs | 4 +- smartcontract/cli/src/link/list.rs | 53 ++++++++++++----- smartcontract/cli/src/link/sethealth.rs | 8 +-- 16 files changed, 158 insertions(+), 66 deletions(-) diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index 347fe13e2e..1444bdc04c 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -273,7 +273,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let tunnel_cloned = tunnel.clone(); @@ -402,7 +402,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link_cloned = link.clone(); @@ -465,7 +465,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let tunnel_clone = tunnel.clone(); @@ -555,7 +555,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // SDK command fetches the link internally @@ -637,7 +637,7 @@ mod tests { doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // SDK command fetches the link internally diff --git a/client/doublezero/src/dzd_latency.rs b/client/doublezero/src/dzd_latency.rs index cec4b93214..58a56c947d 100644 --- a/client/doublezero/src/dzd_latency.rs +++ b/client/doublezero/src/dzd_latency.rs @@ -259,7 +259,7 @@ mod tests { .into_iter() .enumerate() .map(|(i, ip)| { - Interface::V3(CurrentInterfaceVersion { + Interface::V2(CurrentInterfaceVersion { status: InterfaceStatus::Activated, name: format!("Loopback{}", i), interface_type: InterfaceType::Loopback, diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs index ac0de05cec..6eebb850d4 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -1,4 +1,4 @@ -use clap::Args; +use clap::{Args, Subcommand}; use doublezero_cli::doublezerocommand::CliCommand; use doublezero_sdk::commands::{ device::list::ListDeviceCommand, @@ -11,12 +11,24 @@ use std::io::Write; #[derive(Args, Debug)] pub struct MigrateCliCommand { + #[command(subcommand)] + pub command: MigrateCommands, +} + +#[derive(Debug, Subcommand)] +pub enum MigrateCommands { + /// Backfill link topologies and Vpnv4 loopback FlexAlgoNodeSegments (RFC-18 migration) + FlexAlgo(FlexAlgoMigrateCliCommand), +} + +#[derive(Args, Debug)] +pub struct FlexAlgoMigrateCliCommand { /// Print what would be changed without submitting transactions #[arg(long, default_value_t = false)] pub dry_run: bool, } -impl MigrateCliCommand { +impl FlexAlgoMigrateCliCommand { pub fn execute(&self, client: &C, out: &mut W) -> eyre::Result<()> { let program_id = client.get_program_id(); diff --git a/controlplane/doublezero-admin/src/main.rs b/controlplane/doublezero-admin/src/main.rs index 238d84f8c8..253ee4b0ce 100644 --- a/controlplane/doublezero-admin/src/main.rs +++ b/controlplane/doublezero-admin/src/main.rs @@ -256,7 +256,9 @@ async fn main() -> eyre::Result<()> { } }, - Command::Migrate(args) => args.execute(&client, &mut handle), + Command::Migrate(args) => match args.command { + cli::migrate::MigrateCommands::FlexAlgo(cmd) => cmd.execute(&client, &mut handle), + }, Command::Export(args) => args.execute(&client, &mut handle), Command::Keygen(args) => args.execute(&client, &mut handle), Command::Log(args) => args.execute(&dzclient, &mut handle), diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 8868f1e9be..ac79442aa8 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -42,6 +42,7 @@ class AccountTypeEnum(IntEnum): ACCESS_PASS = 11 TENANT = 13 PERMISSION = 15 + TOPOLOGY = 16 # --------------------------------------------------------------------------- @@ -405,7 +406,7 @@ def __str__(self) -> str: @dataclass class FlexAlgoNodeSegment: - topology: bytes = b"\x00" * 32 + topology: Pubkey = Pubkey.default() node_segment_idx: int = 0 @@ -426,10 +427,10 @@ class Interface: ip_net: bytes = b"\x00" * 5 node_segment_idx: int = 0 user_tunnel_endpoint: bool = False - flex_algo_node_segments: list["FlexAlgoNodeSegment"] = field(default_factory=list) + flex_algo_node_segments: list[FlexAlgoNodeSegment] = field(default_factory=list) @classmethod - def from_reader(cls, r: IncrementalReader) -> Interface: + def from_reader(cls, r: DefensiveReader) -> Interface: iface = cls() iface.version = r.read_u8() if iface.version > CURRENT_INTERFACE_VERSION - 1: @@ -458,7 +459,7 @@ def from_reader(cls, r: IncrementalReader) -> 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 == 2: # V3 + if iface.version in (1, 2): # flex_algo_node_segments present in V2 and V3 count = r.read_u32() for _ in range(count): seg = FlexAlgoNodeSegment() @@ -693,6 +694,8 @@ class Link: delay_override_ns: int = 0 link_health: LinkHealth = LinkHealth.UNKNOWN link_desired_status: LinkDesiredStatus = LinkDesiredStatus.PENDING + link_topologies: list[Pubkey] = field(default_factory=list) + link_flags: int = 0 @classmethod def from_bytes(cls, data: bytes) -> Link: @@ -719,6 +722,8 @@ def from_bytes(cls, data: bytes) -> Link: lk.delay_override_ns = r.read_u64() lk.link_health = LinkHealth(r.read_u8()) lk.link_desired_status = LinkDesiredStatus(r.read_u8()) + lk.link_topologies = _read_pubkey_vec(r) + lk.link_flags = r.read_u8() return lk @@ -882,6 +887,7 @@ class Tenant: billing_discriminant: int = 0 billing_rate: int = 0 billing_last_deduction_dz_epoch: int = 0 + include_topologies: list[Pubkey] = field(default_factory=list) @classmethod def from_bytes(cls, data: bytes) -> Tenant: @@ -901,6 +907,7 @@ def from_bytes(cls, data: bytes) -> Tenant: t.billing_discriminant = r.read_u8() t.billing_rate = r.read_u64() t.billing_last_deduction_dz_epoch = r.read_u64() + t.include_topologies = _read_pubkey_vec(r) return t @@ -1009,3 +1016,42 @@ def from_bytes(cls, data: bytes) -> Permission: hi = r.read_u64() p.permissions = lo | (hi << 64) return p + + +# --------------------------------------------------------------------------- +# TopologyInfo +# --------------------------------------------------------------------------- + + +class TopologyConstraint(IntEnum): + INCLUDE_ANY = 0 + EXCLUDE = 1 + + def __str__(self) -> str: + _names = {0: "include-any", 1: "exclude"} + return _names.get(self.value, "unknown") + + +@dataclass +class TopologyInfo: + account_type: int = 0 + owner: Pubkey = Pubkey.default() + bump_seed: int = 0 + name: str = "" + admin_group_bit: int = 0 + flex_algo_number: int = 0 + constraint: TopologyConstraint = TopologyConstraint.INCLUDE_ANY + pub_key: Pubkey = Pubkey.default() # set from account address after deserialization + + @classmethod + def from_bytes(cls, data: bytes) -> TopologyInfo: + r = DefensiveReader(data) + t = cls() + t.account_type = r.read_u8() + t.owner = _read_pubkey(r) + t.bump_seed = r.read_u8() + t.name = r.read_string() + t.admin_group_bit = r.read_u8() + t.flex_algo_number = r.read_u8() + t.constraint = TopologyConstraint(r.read_u8()) + return t diff --git a/sdk/serviceability/testdata/fixtures/device.bin b/sdk/serviceability/testdata/fixtures/device.bin index 8554f47fb112076f8832b9d02e1856f69172d6a7..5e322d10ae5b6e1817d0a5c4a3f3ecc3bfc09d9a 100644 GIT binary patch delta 16 Wcmdnaw3}&zJR=JO5KNY5lmGxCcmoyy delta 32 mcmdnZw4G^#Jfj2;0|NsqLka^kBQpaNgAouiGcYo6F#rHl90N}P diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 1f31a136f0..38e9ef0628 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -324,6 +324,7 @@ fn generate_device(dir: &Path) { ip_net: "172.16.0.1/30".parse().unwrap(), node_segment_idx: 200, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }), ], reference_count: 12, @@ -436,7 +437,7 @@ fn generate_link(dir: &Path) { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: vec![topology_pk], - unicast_drained: true, + link_flags: doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED, }; let data = borsh::to_vec(&val).unwrap(); @@ -468,7 +469,7 @@ fn generate_link(dir: &Path) { FieldValue { name: "DesiredStatus".into(), value: "1".into(), typ: "u8".into() }, FieldValue { name: "LinkTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, FieldValue { name: "LinkTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, - FieldValue { name: "UnicastDrained".into(), value: "true".into(), typ: "bool".into() }, + FieldValue { name: "LinkFlags".into(), value: "1".into(), typ: "u8".into() }, ], }; diff --git a/sdk/serviceability/testdata/fixtures/link.json b/sdk/serviceability/testdata/fixtures/link.json index b5ca99ef35..021c5f9433 100644 --- a/sdk/serviceability/testdata/fixtures/link.json +++ b/sdk/serviceability/testdata/fixtures/link.json @@ -118,9 +118,9 @@ "typ": "pubkey" }, { - "name": "UnicastDrained", - "value": "true", - "typ": "bool" + "name": "LinkFlags", + "value": "1", + "typ": "u8" } ] } \ No newline at end of file diff --git a/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock b/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock index 2255b8f0b8..e4fc896a02 100644 --- a/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock +++ b/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock @@ -583,6 +583,8 @@ dependencies = [ [[package]] name = "doublezero-config" version = "0.16.0" +version = "0.15.0" +>>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "eyre", "serde", @@ -592,6 +594,8 @@ dependencies = [ [[package]] name = "doublezero-program-common" version = "0.16.0" +version = "0.15.0" +>>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "borsh 1.6.0", "byteorder", @@ -604,6 +608,8 @@ dependencies = [ [[package]] name = "doublezero-serviceability" version = "0.16.0" +version = "0.15.0" +>>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "bitflags", "borsh 1.6.0", @@ -619,6 +625,8 @@ dependencies = [ [[package]] name = "doublezero-telemetry" version = "0.16.0" +version = "0.15.0" +>>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "borsh 1.6.0", "borsh-incremental", diff --git a/smartcontract/cli/src/link/accept.rs b/smartcontract/cli/src/link/accept.rs index d5515de747..f5804cd693 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -239,7 +239,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -253,7 +253,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index 1bc7a1c288..f39a069512 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -139,7 +139,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -153,7 +153,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index 9c22168ce3..252b228fb4 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_pubkey_or_code, + validate_parse_mtu, validate_pubkey_or_code, }, }; use clap::Args; @@ -48,8 +48,8 @@ pub struct CreateDZXLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. - #[arg(long, default_value_t = 9000)] + /// MTU (Maximum Transmission Unit) in bytes. + #[arg(long, value_parser = validate_parse_mtu)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -126,17 +126,13 @@ impl CreateDZXLinkCliCommand { )); } - if side_a_iface.mtu != 9000 { + if side_a_iface.mtu != 2048 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 9000", + "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 2048", self.side_a_interface, side_a_iface.mtu )); } - if self.mtu != 9000 { - return Err(eyre!("Link MTU must be 9000")); - } - if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -234,7 +230,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -279,7 +275,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -324,7 +320,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -350,7 +346,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -365,7 +361,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client @@ -407,7 +403,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::DZX, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -425,7 +421,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -453,7 +449,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -556,7 +552,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -657,7 +653,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 2048, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -668,7 +664,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 9000" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 2048" ); } } diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index 36b04f59d8..3f889034ed 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -119,7 +119,9 @@ impl GetLinkCliCommand { health: link.link_health.to_string(), owner: link.owner.to_string(), link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), - unicast_drained: link.unicast_drained, + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, }; if self.json { @@ -175,7 +177,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -189,7 +191,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let contributor = Contributor { @@ -328,7 +330,7 @@ mod tests { assert_eq!(json["status"].as_str().unwrap(), "activated"); assert_eq!(json["tunnel_type"].as_str().unwrap(), "WAN"); assert_eq!(json["bandwidth"].as_u64().unwrap(), 1_000_000_000); - assert_eq!(json["mtu"].as_u64().unwrap(), 9000); + assert_eq!(json["mtu"].as_u64().unwrap(), 1500); assert_eq!(json["contributor"].as_str().unwrap(), "test-contributor"); assert_eq!(json["side_a"].as_str().unwrap(), "side-a-device"); assert_eq!(json["side_z"].as_str().unwrap(), "side-z-device"); diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index bb5c936b81..963e43478a 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -178,7 +178,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, tunnel_id: 1, @@ -191,7 +191,7 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 623dc91d5e..e9e8b6bdce 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -42,6 +42,9 @@ pub struct ListLinkCliCommand { /// Filter by link code (partial match) #[arg(long)] pub code: Option, + /// Filter by topology name (use "default" for links with no topology assignment) + #[arg(long)] + pub topology: Option, /// List only WAN links. #[arg(long, default_value_t = false)] pub wan: bool, @@ -205,6 +208,20 @@ impl ListLinkCliCommand { links.retain(|(_, link)| link.code.contains(code_filter)); } + // Filter by topology if specified + if let Some(topology_filter) = &self.topology { + if topology_filter == "default" { + links.retain(|(_, link)| link.link_topologies.is_empty()); + } else { + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *topology_filter) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", topology_filter))?; + links.retain(|(_, link)| link.link_topologies.contains(&topology_pk)); + } + } + let mut tunnel_displays: Vec = links .into_iter() .map(|(pubkey, link)| { @@ -244,7 +261,9 @@ impl ListLinkCliCommand { health: link.link_health, owner: link.owner, link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), - unicast_drained: link.unicast_drained, + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, } }) .collect(); @@ -407,7 +426,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -429,6 +448,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -450,6 +470,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -607,7 +628,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -620,7 +641,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 40_000, jitter_ns: 2000, delay_override_ns: 0, @@ -634,7 +655,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -657,6 +678,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -788,7 +810,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -802,7 +824,7 @@ mod tests { side_z_pk: device2_pubkey, link_type: LinkLinkType::DZX, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -816,7 +838,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -840,6 +862,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -970,7 +993,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -984,7 +1007,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -998,7 +1021,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -1022,6 +1045,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -1119,7 +1143,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1133,7 +1157,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -1147,7 +1171,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -1171,6 +1195,7 @@ mod tests { health: None, desired_status: None, code: Some("production".to_string()), + topology: None, wan: false, dzx: false, json: false, diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index 24611fd3d1..62ec39956f 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -93,7 +93,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -107,7 +107,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2 = Link { @@ -120,7 +120,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -134,7 +134,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client From 6f08edbc411e900c5db902cda29b9ad96351111e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:29:55 -0500 Subject: [PATCH 33/54] smartcontract: link_flags bitmask, update validation, topology PDA checks, topology_test mtu fixes --- smartcontract/cli/src/link/update.rs | 28 +- smartcontract/cli/src/link/wan_create.rs | 42 ++- smartcontract/cli/src/topology/clear.rs | 196 +++++++++-- smartcontract/cli/src/topology/delete.rs | 2 +- smartcontract/cli/src/topology/list.rs | 2 +- .../src/processors/link/create.rs | 8 +- .../src/processors/link/update.rs | 14 +- .../src/processors/resource/mod.rs | 11 +- .../src/processors/topology/backfill.rs | 44 ++- .../src/processors/topology/create.rs | 40 +-- .../src/processors/topology/delete.rs | 6 +- .../src/state/interface.rs | 142 +------- .../src/state/link.rs | 85 ++++- .../tests/delete_cyoa_interface_test.rs | 11 +- .../tests/link_wan_test.rs | 28 +- .../tests/topology_test.rs | 319 ++++++++++++++++-- ...initialize_device_latency_samples_tests.rs | 16 +- .../sdk/go/serviceability/deserialize.go | 58 +++- smartcontract/sdk/go/serviceability/state.go | 41 ++- .../sdk/go/serviceability/state_test.go | 9 +- .../sdk/rs/src/commands/contributor/create.rs | 1 + .../sdk/rs/src/commands/link/accept.rs | 4 +- .../sdk/rs/src/commands/link/activate.rs | 4 +- .../sdk/rs/src/commands/link/closeaccount.rs | 4 +- .../sdk/rs/src/commands/link/delete.rs | 2 +- .../sdk/rs/src/commands/topology/create.rs | 16 +- smartcontract/test/start-test.sh | 50 ++- 27 files changed, 812 insertions(+), 371 deletions(-) diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 1789e93210..9e25fe3769 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -4,8 +4,8 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, - validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_pubkey, - validate_pubkey_or_code, + validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_parse_mtu, + validate_pubkey, validate_pubkey_or_code, }, }; use clap::Args; @@ -35,8 +35,8 @@ pub struct UpdateLinkCliCommand { /// Updated bandwidth (e.g. 1Gbps, 100Mbps) #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: Option, - /// Updated MTU (Maximum Transmission Unit) in bytes. Must be 9000. - #[arg(long)] + /// Updated MTU (Maximum Transmission Unit) in bytes + #[arg(long, value_parser = validate_parse_mtu)] pub mtu: Option, /// RTT (Round Trip Time) delay in milliseconds #[arg(long, value_parser = validate_parse_delay_ms)] @@ -103,12 +103,6 @@ impl UpdateLinkCliCommand { .transpose() .map_err(|e| eyre!("Invalid status: {e}"))?; - if let Some(mtu) = self.mtu { - if mtu != 9000 { - return Err(eyre!("Link MTU must be 9000")); - } - } - if let Some(ref code) = self.code { if link.code != *code && client @@ -223,7 +217,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -237,7 +231,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let link2 = Link { @@ -250,7 +244,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -264,7 +258,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client @@ -303,7 +297,7 @@ mod tests { contributor_pk: Some(contributor_pk), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(9000), + mtu: Some(1500), delay_ns: Some(10000000), jitter_ns: Some(5000000), delay_override_ns: None, @@ -324,7 +318,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(9000), + mtu: Some(1500), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, @@ -351,7 +345,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(9000), + mtu: Some(1500), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index e7e44cc74f..a443f626a8 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_pubkey_or_code, + validate_parse_mtu, validate_pubkey_or_code, }, }; use clap::Args; @@ -51,8 +51,8 @@ pub struct CreateWANLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. - #[arg(long, default_value_t = 9000)] + /// MTU (Maximum Transmission Unit) in bytes. + #[arg(long, value_parser = validate_parse_mtu)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -129,9 +129,9 @@ impl CreateWANLinkCliCommand { )); } - if side_a_iface.mtu != 9000 { + if side_a_iface.mtu != 2048 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 9000", + "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 2048", self.side_a_interface, side_a_iface.mtu )); } @@ -171,17 +171,13 @@ impl CreateWANLinkCliCommand { )); } - if side_z_iface.mtu != 9000 { + if side_z_iface.mtu != 2048 { return Err(eyre!( - "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 9000", + "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 2048", self.side_z_interface, side_z_iface.mtu )); } - if self.mtu != 9000 { - return Err(eyre!("Link MTU must be 9000")); - } - if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -281,7 +277,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -326,7 +322,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -371,7 +367,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 9000, + mtu: 2048, ..Default::default() } .to_interface()], @@ -397,7 +393,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -412,7 +408,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; client @@ -454,7 +450,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -472,7 +468,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -501,7 +497,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -639,7 +635,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -727,7 +723,7 @@ mod tests { name: "Ethernet1/2".to_string(), interface_type: InterfaceType::Physical, loopback_type: LoopbackType::None, - mtu: 9000, + mtu: 2048, vlan_id: 16, ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, @@ -774,7 +770,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 9000, + mtu: 2048, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -786,7 +782,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 9000" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 2048" ); } } diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs index 81a925a614..928ce995c9 100644 --- a/smartcontract/cli/src/topology/clear.rs +++ b/smartcontract/cli/src/topology/clear.rs @@ -7,12 +7,17 @@ use doublezero_sdk::commands::topology::clear::ClearTopologyCommand; use solana_sdk::pubkey::Pubkey; use std::io::Write; +// Solana transactions have a 32-account limit. With 3 fixed accounts (topology PDA, +// globalstate, payer), we can fit at most 29 link accounts per transaction. +const CLEAR_BATCH_SIZE: usize = 29; + #[derive(Args, Debug)] pub struct ClearTopologyCliCommand { /// Name of the topology to clear from links #[arg(long)] pub name: String, - /// Comma-separated list of link pubkeys to clear the topology from + /// Comma-separated list of link pubkeys to clear the topology from. + /// If omitted, all links currently tagged with this topology are discovered automatically. #[arg(long, value_delimiter = ',')] pub links: Vec, } @@ -21,21 +26,56 @@ impl ClearTopologyCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; - let link_pubkeys: Vec = self - .links - .iter() - .map(|s| { - s.parse::() - .map_err(|_| eyre::eyre!("invalid link pubkey: {}", s)) - }) - .collect::>>()?; + let link_pubkeys: Vec = if self.links.is_empty() { + // Auto-discover: find all links tagged with this topology. + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand)?; + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == self.name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", self.name))?; + + let links = client.list_link(doublezero_sdk::commands::link::list::ListLinkCommand)?; + links + .into_iter() + .filter(|(_, link)| link.link_topologies.contains(&topology_pk)) + .map(|(pk, _)| pk) + .collect() + } else { + self.links + .iter() + .map(|s| { + s.parse::() + .map_err(|_| eyre::eyre!("invalid link pubkey: {}", s)) + }) + .collect::>>()? + }; + + let total = link_pubkeys.len(); + + if total == 0 { + writeln!( + out, + "No links tagged with topology '{}'. Nothing to clear.", + self.name + )?; + return Ok(()); + } - let n = link_pubkeys.len(); - client.clear_topology(ClearTopologyCommand { - name: self.name.clone(), - link_pubkeys, - })?; - writeln!(out, "Cleared topology '{}' from {} link(s).", self.name, n)?; + // Batch into chunks that fit within Solana's account limit. + for chunk in link_pubkeys.chunks(CLEAR_BATCH_SIZE) { + client.clear_topology(ClearTopologyCommand { + name: self.name.clone(), + link_pubkeys: chunk.to_vec(), + })?; + } + + writeln!( + out, + "Cleared topology '{}' from {} link(s).", + self.name, total + )?; Ok(()) } @@ -45,21 +85,36 @@ impl ClearTopologyCliCommand { mod tests { use super::*; use crate::doublezerocommand::MockCliCommand; + use doublezero_sdk::{Link, TopologyInfo}; use mockall::predicate::eq; use solana_sdk::{pubkey::Pubkey, signature::Signature}; - use std::io::Cursor; + use std::{collections::HashMap, io::Cursor}; #[test] - fn test_clear_topology_execute_no_links() { + fn test_clear_topology_execute_no_links_auto_discover_empty() { + // When links is empty, auto-discovery runs but finds no tagged links. let mut mock = MockCliCommand::new(); + let topology_pk = Pubkey::new_unique(); - mock.expect_check_requirements().returning(|_| Ok(())); - mock.expect_clear_topology() - .with(eq(ClearTopologyCommand { + let mut topology_map: HashMap = HashMap::new(); + topology_map.insert( + topology_pk, + TopologyInfo { + account_type: doublezero_sdk::AccountType::Topology, + owner: Pubkey::default(), + bump_seed: 0, name: "unicast-default".to_string(), - link_pubkeys: vec![], - })) - .returning(|_| Ok(Signature::new_unique())); + admin_group_bit: 1, + flex_algo_number: 129, + constraint: + doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }, + ); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(move |_| Ok(topology_map.clone())); + mock.expect_list_link().returning(|_| Ok(HashMap::new())); let cmd = ClearTopologyCliCommand { name: "unicast-default".to_string(), @@ -69,7 +124,7 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_ok()); let output = String::from_utf8(out.into_inner()).unwrap(); - assert!(output.contains("Cleared topology 'unicast-default' from 0 link(s).")); + assert!(output.contains("No links tagged with topology 'unicast-default'.")); } #[test] @@ -111,4 +166,97 @@ mod tests { let result = cmd.execute(&mock, &mut out); assert!(result.is_err()); } + + #[test] + fn test_clear_topology_auto_discover() { + let mut mock = MockCliCommand::new(); + let topology_pk = Pubkey::new_unique(); + let other_topology_pk = Pubkey::new_unique(); + let link1_pk = Pubkey::new_unique(); + let link2_pk = Pubkey::new_unique(); + let untagged_pk = Pubkey::new_unique(); + + let mut topology_map: HashMap = HashMap::new(); + topology_map.insert( + topology_pk, + TopologyInfo { + account_type: doublezero_sdk::AccountType::Topology, + owner: Pubkey::default(), + bump_seed: 0, + name: "my-topo".to_string(), + admin_group_bit: 1, + flex_algo_number: 129, + constraint: + doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }, + ); + + let mut links: HashMap = HashMap::new(); + links.insert( + link1_pk, + Link { + link_topologies: vec![topology_pk], + ..Default::default() + }, + ); + links.insert( + link2_pk, + Link { + link_topologies: vec![topology_pk, other_topology_pk], + ..Default::default() + }, + ); + links.insert( + untagged_pk, + Link { + link_topologies: vec![], + ..Default::default() + }, + ); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(move |_| Ok(topology_map.clone())); + mock.expect_list_link() + .returning(move |_| Ok(links.clone())); + mock.expect_clear_topology() + .withf(move |cmd| { + cmd.name == "my-topo" + && cmd.link_pubkeys.len() == 2 + && cmd.link_pubkeys.contains(&link1_pk) + && cmd.link_pubkeys.contains(&link2_pk) + }) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "my-topo".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok(), "{:?}", result); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'my-topo' from 2 link(s).")); + } + + #[test] + fn test_clear_topology_auto_discover_not_found() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(|_| Ok(HashMap::new())); + + let cmd = ClearTopologyCliCommand { + name: "nonexistent".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Topology 'nonexistent' not found")); + } } diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs index 487f4d23f0..d92cd7a661 100644 --- a/smartcontract/cli/src/topology/delete.rs +++ b/smartcontract/cli/src/topology/delete.rs @@ -118,7 +118,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: vec![topology_pda], - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/cli/src/topology/list.rs b/smartcontract/cli/src/topology/list.rs index 9f67f462a3..fa57dcccf0 100644 --- a/smartcontract/cli/src/topology/list.rs +++ b/smartcontract/cli/src/topology/list.rs @@ -199,7 +199,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: vec![topology_pda], - unicast_drained: false, + link_flags: 0, }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index fe7f440285..22b36e0beb 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -9,7 +9,7 @@ use crate::{ contributor::Contributor, device::Device, globalstate::GlobalState, - interface::{InterfaceCYOA, InterfaceDIA, InterfaceStatus, LINK_MTU}, + interface::{InterfaceCYOA, InterfaceDIA, InterfaceStatus}, link::*, }, }; @@ -189,10 +189,6 @@ pub fn process_create_link( return Err(DoubleZeroError::InvalidInterfaceZForExternal.into()); } - if value.mtu != LINK_MTU { - return Err(DoubleZeroError::InvalidMtu.into()); - } - let status = if value.link_type == LinkLinkType::DZX { LinkStatus::Requested } else { @@ -228,7 +224,7 @@ pub fn process_create_link( link_health: LinkHealth::ReadyForService, // Force the link to be ready for service until the health oracle is implemented, desired_status: value.desired_status.unwrap_or(LinkDesiredStatus::Activated), link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; link.check_status_transition(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 90f361b78f..5119fe68ed 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -1,5 +1,5 @@ use crate::{ - error::DoubleZeroError, + error::{DoubleZeroError, Validate}, pda::get_resource_extension_pda, processors::{ resource::{allocate_specific_id, allocate_specific_ip, deallocate_id, deallocate_ip}, @@ -228,9 +228,6 @@ pub fn process_update_link( link.bandwidth = bandwidth; } if let Some(mtu) = value.mtu { - if mtu != crate::state::interface::LINK_MTU { - return Err(DoubleZeroError::InvalidMtu.into()); - } link.mtu = mtu; } if let Some(delay_ns) = value.delay_ns { @@ -382,7 +379,7 @@ pub fn process_update_link( link.link_topologies = link_topologies.clone(); } - // unicast_drained: contributor A or foundation + // unicast_drained (LINK_FLAG_UNICAST_DRAINED bit 0): contributor A or foundation if let Some(unicast_drained) = value.unicast_drained { if link.contributor_pk != *contributor_account.key && !globalstate.foundation_allowlist.contains(payer_account.key) @@ -390,10 +387,15 @@ pub fn process_update_link( msg!("unicast_drained update requires contributor A or foundation allowlist"); return Err(DoubleZeroError::NotAllowed.into()); } - link.unicast_drained = unicast_drained; + if unicast_drained { + link.link_flags |= crate::state::link::LINK_FLAG_UNICAST_DRAINED; + } else { + link.link_flags &= !crate::state::link::LINK_FLAG_UNICAST_DRAINED; + } } link.check_status_transition(); + link.validate()?; try_acc_write(&link, link_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs index 8f9dc19a1f..565171f171 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs @@ -69,7 +69,7 @@ pub fn get_resource_extension_range( ResourceType::LinkIds => ResourceExtensionRange::IdRange(0, 65535), ResourceType::SegmentRoutingIds => ResourceExtensionRange::IdRange(1, 65535), ResourceType::VrfIds => ResourceExtensionRange::IdRange(1, 1024), - ResourceType::AdminGroupBits => ResourceExtensionRange::IdRange(0, 127), + ResourceType::AdminGroupBits => ResourceExtensionRange::IdRange(1, 127), } } @@ -172,15 +172,6 @@ pub fn create_resource( resource.allocate(1)?; // Allocates index 0 } - // Pre-mark bit 1 (UNICAST-DRAINED) so it is never allocated to a user topology. - // IS-IS flex-algo admin-group bit 1 is reserved for the UNICAST-DRAINED topology - // and must never be reused. - if let ResourceType::AdminGroupBits = resource_type { - let mut buffer = resource_account.data.borrow_mut(); - let mut resource = ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; - resource.allocate_specific(&crate::resource::IdOrIp::Id(1))?; - } - Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs index 33452554b6..4bef8b0176 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs @@ -1,7 +1,7 @@ use crate::{ error::DoubleZeroError, pda::{get_resource_extension_pda, get_topology_pda}, - processors::resource::allocate_id, + processors::resource::{allocate_id, allocate_specific_id}, resource::ResourceType, serializer::try_acc_write, state::{ @@ -88,7 +88,35 @@ pub fn process_topology_backfill( let mut backfilled_count: usize = 0; let mut skipped_count: usize = 0; - for device_account in accounts_iter { + // Collect device accounts for two-pass processing. + let device_accounts: Vec<&AccountInfo> = accounts_iter.collect(); + + // First pass: pre-mark all existing node_segment_idx values as used in the + // SegmentRoutingIds resource. This prevents collisions when the activator + // manages SR IDs in-memory (use_onchain_allocation=false) and the on-chain + // resource hasn't been updated to reflect those allocations. + for device_account in &device_accounts { + if device_account.owner != program_id { + continue; + } + let device = match Device::try_from(&device_account.data.borrow()[..]) { + Ok(d) => d, + Err(_) => continue, + }; + for iface in device.interfaces.iter() { + let current = iface.into_current_version(); + if current.node_segment_idx > 0 { + // Ignore error: ID may already be marked (idempotent pre-mark). + let _ = allocate_specific_id(segment_routing_ids_account, current.node_segment_idx); + } + for fas in ¤t.flex_algo_node_segments { + let _ = allocate_specific_id(segment_routing_ids_account, fas.node_segment_idx); + } + } + } + + // Second pass: allocate new IDs for loopbacks missing this topology's segment. + for device_account in &device_accounts { if device_account.owner != program_id { continue; } @@ -98,12 +126,12 @@ pub fn process_topology_backfill( }; let mut modified = false; for iface in device.interfaces.iter_mut() { - let iface_v3 = iface.into_current_version(); - if iface_v3.loopback_type != LoopbackType::Vpnv4 { + let iface_v2 = iface.into_current_version(); + if iface_v2.loopback_type != LoopbackType::Vpnv4 { continue; } // Skip if already has a segment for this topology (idempotent) - if iface_v3 + if iface_v2 .flex_algo_node_segments .iter() .any(|s| &s.topology == topology_key) @@ -113,8 +141,8 @@ pub fn process_topology_backfill( } let node_segment_idx = allocate_id(segment_routing_ids_account)?; match iface { - Interface::V3(ref mut v3) => { - v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_key, node_segment_idx, }); @@ -125,7 +153,7 @@ pub fn process_topology_backfill( topology: *topology_key, node_segment_idx, }); - *iface = Interface::V3(upgraded); + *iface = Interface::V2(upgraded); } } modified = true; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs index 48c7263cee..82035c216a 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -1,7 +1,7 @@ use crate::{ error::DoubleZeroError, - pda::get_topology_pda, - processors::resource::allocate_id, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::{resource::allocate_id, validation::validate_program_account}, resource::ResourceType, seeds::{SEED_PREFIX, SEED_TOPOLOGY}, serializer::{try_acc_create, try_acc_write}, @@ -85,18 +85,18 @@ pub fn process_topology_create( } // Validate AdminGroupBits resource account - assert_eq!( - admin_group_bits_account.owner, program_id, - "TopologyCreate: invalid AdminGroupBits account owner" + let (expected_ab_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::AdminGroupBits); + validate_program_account!( + admin_group_bits_account, + program_id, + writable = true, + pda = Some(&expected_ab_pda), + "AdminGroupBits" ); - // Allocate admin_group_bit (lowest available; bit 1 is pre-marked = never returned) - let admin_group_bit_u16 = allocate_id(admin_group_bits_account)?; - if admin_group_bit_u16 > 127 { - msg!("TopologyCreate: AdminGroupBits exhausted (max 128 topologies)"); - return Err(DoubleZeroError::AllocationFailed.into()); - } - let admin_group_bit = admin_group_bit_u16 as u8; + // Allocate admin_group_bit (lowest available bit in IdRange) + let admin_group_bit = allocate_id(admin_group_bits_account)? as u8; let flex_algo_number = 128u8 .checked_add(admin_group_bit) .ok_or(DoubleZeroError::ArithmeticOverflow)?; @@ -149,12 +149,12 @@ pub fn process_topology_create( let mut device = Device::try_from(&device_account.data.borrow()[..])?; let mut modified = false; for iface in device.interfaces.iter_mut() { - let iface_v3 = iface.into_current_version(); - if iface_v3.loopback_type != LoopbackType::Vpnv4 { + let iface_v2 = iface.into_current_version(); + if iface_v2.loopback_type != LoopbackType::Vpnv4 { continue; } // Skip if already has a segment for this topology (idempotent) - if iface_v3 + if iface_v2 .flex_algo_node_segments .iter() .any(|s| &s.topology == topology_account.key) @@ -162,22 +162,22 @@ pub fn process_topology_create( continue; } let node_segment_idx = allocate_id(segment_routing_ids_account)?; - // Mutate the interface in place — we need to upgrade to V3 if needed + // Mutate the interface in place — upgrade to V2 if needed match iface { - Interface::V3(ref mut v3) => { - v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_account.key, node_segment_idx, }); } _ => { - // Upgrade to V3 with the segment added + // Upgrade to current version (V2) with the segment added let mut upgraded = iface.into_current_version(); upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_account.key, node_segment_idx, }); - *iface = Interface::V3(upgraded); + *iface = Interface::V2(upgraded); } } modified = true; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs index d42a37c534..e70e318237 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -76,7 +76,11 @@ pub fn process_topology_delete( } // Close the topology PDA (transfer lamports to payer, zero data) - // NOTE: We do NOT deallocate the admin-group bit — bits are permanently marked. + // NOTE: We do NOT deallocate the admin-group bit — bits are permanently retired. + // If a bit were reused for a new topology, any IS-IS router still advertising + // link memberships for the deleted topology would classify traffic onto the new + // topology's flex-algo path until the network fully converges, causing misrouting. + // Admin-group bits are a cheap resource (128 total), so permanent allocation is safe. try_acc_close(topology_account, payer_account)?; msg!("TopologyDelete: closed topology '{}'", value.name); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index 9f21784ddd..c78143b6ca 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -4,10 +4,6 @@ use doublezero_program_common::{types::NetworkV4, validate_iface}; use solana_program::{msg, program_error::ProgramError}; use std::{fmt, str::FromStr}; -pub const LINK_MTU: u32 = 9000; -pub const INTERFACE_MTU: u16 = 9000; -pub const CYOA_DIA_INTERFACE_MTU: u16 = 1500; - #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Copy, Clone, PartialEq, Default)] #[borsh(use_discriminant = true)] @@ -308,6 +304,7 @@ pub struct InterfaceV2 { pub ip_net: NetworkV4, // 4 IPv4 address + 1 subnet mask pub node_segment_idx: u16, // 2 pub user_tunnel_endpoint: bool, // 1 + pub flex_algo_node_segments: Vec, } impl InterfaceV2 { @@ -320,7 +317,7 @@ impl InterfaceV2 { } 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 + 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) } } @@ -346,6 +343,10 @@ impl TryFrom<&[u8]> for InterfaceV2 { let val: u8 = BorshDeserialize::deserialize(&mut data).unwrap_or_default(); val != 0 }, + // flex_algo_node_segments was added in the same version as this field set. + // Old on-chain V2 accounts (written before this field existed) will have no + // trailing bytes here — unwrap_or_default() yields an empty vec. + flex_algo_node_segments: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }) } } @@ -363,113 +364,8 @@ impl TryFrom<&InterfaceV1> for InterfaceV2 { loopback_type: data.loopback_type, bandwidth: 0, cir: 0, - mtu: INTERFACE_MTU, - routing_mode: RoutingMode::Static, - 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 { - status: InterfaceStatus::Pending, - name: String::default(), - interface_type: InterfaceType::Invalid, - interface_cyoa: InterfaceCYOA::None, - interface_dia: InterfaceDIA::None, - loopback_type: LoopbackType::None, - bandwidth: 0, - cir: 0, - mtu: INTERFACE_MTU, + mtu: 1500, routing_mode: RoutingMode::Static, - vlan_id: 0, - ip_net: NetworkV4::default(), - node_segment_idx: 0, - user_tunnel_endpoint: false, - } - } -} - -#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct InterfaceV3 { - pub status: InterfaceStatus, // 1 - pub name: String, // 4 + len - pub interface_type: InterfaceType, // 1 - pub interface_cyoa: InterfaceCYOA, // 1 - pub interface_dia: InterfaceDIA, // 1 - pub loopback_type: LoopbackType, // 1 - pub bandwidth: u64, // 8 - pub cir: u64, // 8 - pub mtu: u16, // 2 - pub routing_mode: RoutingMode, // 1 - pub vlan_id: u16, // 2 - pub ip_net: NetworkV4, // 4 IPv4 address + 1 subnet mask - pub node_segment_idx: u16, // 2 - pub user_tunnel_endpoint: bool, // 1 - 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) -> Interface { - Interface::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 TryFrom<&[u8]> for InterfaceV3 { - type Error = ProgramError; - - fn try_from(mut data: &[u8]) -> Result { - Ok(Self { - status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - interface_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - interface_cyoa: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - interface_dia: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - loopback_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - bandwidth: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - cir: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - mtu: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - routing_mode: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - vlan_id: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - ip_net: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - node_segment_idx: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - user_tunnel_endpoint: { - let val: u8 = BorshDeserialize::deserialize(&mut data).unwrap_or_default(); - val != 0 - }, - flex_algo_node_segments: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - }) - } -} - -impl TryFrom<&InterfaceV2> for InterfaceV3 { - type Error = ProgramError; - - fn try_from(data: &InterfaceV2) -> 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, @@ -479,7 +375,7 @@ impl TryFrom<&InterfaceV2> for InterfaceV3 { } } -impl Default for InterfaceV3 { +impl Default for InterfaceV2 { fn default() -> Self { Self { status: InterfaceStatus::Pending, @@ -508,20 +404,15 @@ impl Default for InterfaceV3 { pub enum Interface { V1(InterfaceV1), V2(InterfaceV2), - V3(InterfaceV3), } -pub type CurrentInterfaceVersion = InterfaceV3; +pub type CurrentInterfaceVersion = InterfaceV2; impl Interface { pub fn into_current_version(&self) -> CurrentInterfaceVersion { match self { - Interface::V1(v1) => { - let v2: InterfaceV2 = v1.try_into().unwrap_or_default(); - InterfaceV3::try_from(&v2).unwrap_or_default() - } - Interface::V2(v2) => InterfaceV3::try_from(v2).unwrap_or_default(), - Interface::V3(v3) => v3.clone(), + Interface::V1(v1) => v1.try_into().unwrap_or_default(), + Interface::V2(v2) => v2.clone(), } } @@ -529,7 +420,6 @@ impl Interface { let base_size = match self { Interface::V1(v1) => v1.size(), Interface::V2(v2) => v2.size(), - Interface::V3(v3) => v3.size(), }; base_size + 1 // +1 for the enum discriminant } @@ -594,9 +484,10 @@ impl TryFrom<&[u8]> for Interface { fn try_from(mut data: &[u8]) -> Result { match BorshDeserialize::deserialize(&mut data) { Ok(0u8) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), - Ok(1u8) => Ok(Interface::V2(InterfaceV2::try_from(data)?)), - Ok(2u8) => Ok(Interface::V3(InterfaceV3::try_from(data)?)), - _ => Ok(Interface::V3(InterfaceV3::default())), + // Discriminant 1 = V2 (current). Discriminant 2 was the old V3 which has + // identical layout to V2 — treat it as V2 for backward compatibility. + Ok(1u8) | Ok(2u8) => Ok(Interface::V2(InterfaceV2::try_from(data)?)), + _ => Ok(Interface::V2(InterfaceV2::default())), } } } @@ -643,6 +534,7 @@ fn test_interface_version() { ip_net: "10.0.0.0/24".parse().unwrap(), node_segment_idx: 200, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(); @@ -681,6 +573,7 @@ mod test_interface_validate { ip_net: NetworkV4::default(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } } @@ -786,6 +679,7 @@ mod test_interface_validate { ip_net: "203.0.113.40/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }; // Serialize as Interface::V2 (with enum discriminant) diff --git a/smartcontract/programs/doublezero-serviceability/src/state/link.rs b/smartcontract/programs/doublezero-serviceability/src/state/link.rs index 95d726e48a..49371567cd 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/link.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/link.rs @@ -265,15 +265,19 @@ pub struct Link { pub link_health: LinkHealth, // 1 pub desired_status: LinkDesiredStatus, // 1 pub link_topologies: Vec, // 4 + 32 * len - pub unicast_drained: bool, // 1 + pub link_flags: u8, // 1 — bitmask; see LINK_FLAG_* constants } +/// Bit 0 of `link_flags`: link is administratively drained from unicast traffic. +/// Maps to IS-IS admin-group UNICAST-DRAINED (group 0). +pub const LINK_FLAG_UNICAST_DRAINED: u8 = 0x01; + impl fmt::Display for Link { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}, unicast_drained: {}", - self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies, self.unicast_drained + "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}, link_flags: {:#04x}", + self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies, self.link_flags ) } } @@ -303,7 +307,7 @@ impl Default for Link { link_health: LinkHealth::Pending, desired_status: LinkDesiredStatus::Pending, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, } } } @@ -335,7 +339,7 @@ impl TryFrom<&[u8]> for Link { link_health: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), desired_status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), link_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), - unicast_drained: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + link_flags: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Link { @@ -410,6 +414,14 @@ impl Validate for Link { msg!("Invalid link endpoints: side_a_pk and side_z_pk must be different"); return Err(DoubleZeroError::InvalidDevicePubkey); } + // A link may belong to at most 8 topologies + if self.link_topologies.len() > 8 { + msg!( + "link_topologies exceeds maximum of 8 (got {})", + self.link_topologies.len() + ); + return Err(DoubleZeroError::InvalidArgument); + } Ok(()) } } @@ -432,6 +444,10 @@ impl Link { /// This method mutates the `status` field of the `Link` in-place. /// Where `_` means any value is valid for that field. /// + pub fn is_unicast_drained(&self) -> bool { + self.link_flags & LINK_FLAG_UNICAST_DRAINED != 0 + } + #[allow(unreachable_code)] pub fn check_status_transition(&mut self) { // waiting for health oracle to implement this logic @@ -556,7 +572,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let data = borsh::to_vec(&val).unwrap(); @@ -611,7 +627,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -643,7 +659,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -675,7 +691,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // For Rejected status, tunnel_net is not validated and should succeed @@ -707,7 +723,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -739,7 +755,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -779,7 +795,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -820,7 +836,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err = val.validate(); @@ -853,7 +869,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -893,7 +909,7 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -933,8 +949,45 @@ mod tests { link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; assert!(bad_link.validate().is_ok()); } + + #[test] + fn test_state_link_validate_error_too_many_topologies() { + let valid_link = Link { + account_type: AccountType::Link, + owner: Pubkey::new_unique(), + index: 123, + bump_seed: 1, + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 1566, + delay_ns: 1_000_000, + jitter_ns: 1_000_000, + tunnel_id: 1, + tunnel_net: "10.0.0.1/25".parse().unwrap(), + code: "test-123".to_string(), + status: LinkStatus::Activated, + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + delay_override_ns: 0, + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: (0..8).map(|_| Pubkey::new_unique()).collect(), + link_flags: 0, + }; + assert!(valid_link.validate().is_ok()); + + let too_many = Link { + link_topologies: (0..9).map(|_| Pubkey::new_unique()).collect(), + ..valid_link + }; + let err = too_many.validate(); + assert_eq!(err.unwrap_err(), DoubleZeroError::InvalidArgument); + } } diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs index 4b5cd52b5f..376096ca18 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs @@ -104,6 +104,7 @@ async fn test_delete_cyoa_interface_with_invalid_sibling() { ip_net: "63.243.225.62/30".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; // Interface B: INVALID CYOA interface — CYOA set but ip_net is default (0.0.0.0/0). @@ -123,6 +124,7 @@ async fn test_delete_cyoa_interface_with_invalid_sibling() { ip_net: NetworkV4::default(), // <-- INVALID: CYOA without ip_net node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; let device = Device { @@ -337,6 +339,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { ip_net: "63.243.225.62/30".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; // Interface B: INVALID CYOA interface — CYOA set but ip_net is default. @@ -355,6 +358,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { ip_net: NetworkV4::default(), // <-- INVALID: CYOA without ip_net node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; let device = Device { @@ -410,7 +414,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { program_id, DoubleZeroInstruction::UpdateDeviceInterface(DeviceInterfaceUpdateArgs { name: "ethernet1".to_string(), - mtu: Some(1500), + mtu: Some(9000), ..Default::default() }), vec![ @@ -434,10 +438,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { .unwrap(); let updated_iface = device.find_interface("Ethernet1").unwrap().1; - assert_eq!( - updated_iface.mtu, 1500, - "MTU should remain 1500 for CYOA interface" - ); + assert_eq!(updated_iface.mtu, 9000, "MTU should be updated to 9000"); assert_eq!( updated_iface.ip_net, "63.243.225.62/30".parse().unwrap(), diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 21b6221f54..00d313c561 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -231,7 +231,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -362,7 +362,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -610,7 +610,7 @@ async fn test_wan_link() { contributor_pk: Some(contributor_pubkey), tunnel_type: Some(LinkLinkType::WAN), bandwidth: Some(20000000000), - mtu: Some(9000), + mtu: Some(8900), delay_ns: Some(1000000), jitter_ns: Some(100000), delay_override_ns: Some(0), @@ -639,7 +639,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 9000); + assert_eq!(tunnel_la.mtu, 8900); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Activated); assert_eq!(tunnel_la.desired_status, LinkDesiredStatus::Activated); @@ -823,7 +823,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 9000); + assert_eq!(tunnel_la.mtu, 8900); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Deleting); @@ -1149,7 +1149,7 @@ async fn test_wan_link_rejects_cyoa_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1249,7 +1249,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(9000), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1277,7 +1277,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(1500), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1341,7 +1341,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(9000), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1398,7 +1398,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: Some(1500), + mtu: None, routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1623,7 +1623,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1714,7 +1714,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2088,7 +2088,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2176,7 +2176,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 4c6a69d13e..9173cf8574 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1,4 +1,4 @@ -//! Tests for TopologyInfo, FlexAlgoNodeSegment, and InterfaceV3 (RFC-18 / Link Classification). +//! Tests for TopologyInfo and FlexAlgoNodeSegment (RFC-18 / Link Classification). use doublezero_serviceability::{ instructions::DoubleZeroInstruction, @@ -24,7 +24,7 @@ use doublezero_serviceability::{ delete::TopologyDeleteArgs, }, }, - resource::{IdOrIp, ResourceType}, + resource::ResourceType, state::{ accounttype::AccountType, device::{DeviceDesiredStatus, DeviceType}, @@ -105,17 +105,17 @@ async fn test_admin_group_bits_create_and_pre_mark() { "AdminGroupBits account should have non-empty data" ); - // Verify bit 1 (UNICAST-DRAINED) is pre-marked + // Bit 0 is implicitly reserved for UNICAST-DRAINED via IdRange(1, 127). + // No bits are pre-marked at resource creation time. let resource = get_resource_extension_data(&mut banks_client, resource_pubkey) .await .expect("AdminGroupBits resource extension should be deserializable"); let allocated = resource.iter_allocated(); - assert_eq!(allocated.len(), 1, "exactly one bit should be pre-marked"); assert_eq!( - allocated[0], - IdOrIp::Id(1), - "bit 1 (UNICAST-DRAINED) should be pre-marked" + allocated.len(), + 0, + "no bits should be pre-marked at creation" ); println!("[PASS] test_admin_group_bits_create_and_pre_mark"); @@ -160,8 +160,8 @@ fn test_flex_algo_node_segment_roundtrip() { // ============================================================================ #[tokio::test] -async fn test_topology_create_bit_0_first() { - println!("[TEST] test_topology_create_bit_0_first"); +async fn test_topology_create_bit_1_first() { + println!("[TEST] test_topology_create_bit_1_first"); let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; @@ -184,16 +184,18 @@ async fn test_topology_create_bit_0_first() { assert_eq!(topology.account_type, AccountType::Topology); assert_eq!(topology.name, "unicast-default"); - assert_eq!(topology.admin_group_bit, 0); - assert_eq!(topology.flex_algo_number, 128); + // Bit 0 is reserved for UNICAST-DRAINED (implicit via IdRange(1, 127)), + // so the first user topology gets bit 1. + assert_eq!(topology.admin_group_bit, 1); + assert_eq!(topology.flex_algo_number, 129); assert_eq!(topology.constraint, TopologyConstraint::IncludeAny); - println!("[PASS] test_topology_create_bit_0_first"); + println!("[PASS] test_topology_create_bit_1_first"); } #[tokio::test] -async fn test_topology_create_second_skips_bit_1() { - println!("[TEST] test_topology_create_second_skips_bit_1"); +async fn test_topology_create_consecutive_bits() { + println!("[TEST] test_topology_create_consecutive_bits"); let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = setup_program_with_globalconfig().await; @@ -201,7 +203,7 @@ async fn test_topology_create_second_skips_bit_1() { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - // First topology gets bit 0 + // First topology gets bit 1 (bit 0 is implicitly reserved for UNICAST-DRAINED) create_topology( &mut banks_client, program_id, @@ -213,7 +215,7 @@ async fn test_topology_create_second_skips_bit_1() { ) .await; - // Second topology must skip bit 1 (pre-marked UNICAST-DRAINED) and get bit 2 + // Second topology gets the next consecutive bit (2) let topology_pda = create_topology( &mut banks_client, program_id, @@ -228,13 +230,10 @@ async fn test_topology_create_second_skips_bit_1() { let topology = get_topology(&mut banks_client, topology_pda).await; assert_eq!(topology.name, "shelby"); - assert_eq!( - topology.admin_group_bit, 2, - "bit 1 should be skipped (UNICAST-DRAINED)" - ); + assert_eq!(topology.admin_group_bit, 2); assert_eq!(topology.flex_algo_number, 130); - println!("[PASS] test_topology_create_second_skips_bit_1"); + println!("[PASS] test_topology_create_consecutive_bits"); } #[tokio::test] @@ -1221,7 +1220,7 @@ async fn test_topology_delete_bit_not_reused() { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); - // Create "topology-a" — gets bit 0 + // Create "topology-a" — gets bit 1 (first available since bit 0 is reserved for UNICAST-DRAINED) create_topology( &mut banks_client, program_id, @@ -1245,7 +1244,7 @@ async fn test_topology_delete_bit_not_reused() { .await .expect("Delete should succeed"); - // Create "topology-b" — must NOT get bit 0 (permanently marked) or bit 1 (UNICAST-DRAINED) + // Create "topology-b" — must NOT get bit 1 (permanently marked even after delete), // so it should get bit 2 let topology_b_pda = create_topology( &mut banks_client, @@ -1261,7 +1260,7 @@ async fn test_topology_delete_bit_not_reused() { let topology_b = get_topology(&mut banks_client, topology_b_pda).await; assert_eq!( topology_b.admin_group_bit, 2, - "topology-b should get bit 2 (bit 0 permanently marked even after delete, bit 1 is UNICAST-DRAINED)" + "topology-b should get bit 2 (bit 1 permanently marked even after delete)" ); println!("[PASS] test_topology_delete_bit_not_reused"); @@ -1890,6 +1889,265 @@ async fn test_topology_backfill_nonexistent_topology_rejected() { println!("[PASS] test_topology_backfill_nonexistent_topology_rejected"); } +#[tokio::test] +async fn test_topology_backfill_avoids_collision_with_existing_node_segment_idx() { + // Regression test: BackfillTopology must not re-use the base node_segment_idx + // when the on-chain SegmentRoutingIds resource was never updated by the activator + // (use_onchain_allocation=false path). Before the fix, backfill would allocate + // ID 1 for the flex-algo segment even though ID 1 was already used as the base + // node_segment_idx on the loopback. + println!("[TEST] test_topology_backfill_avoids_collision_with_existing_node_segment_idx"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface (without onchain allocation) + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback255".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Activate the loopback with explicit node_segment_idx=1, WITHOUT providing + // the SegmentRoutingIds account. This simulates the activator's use_onchain_allocation=false + // path: the base SR ID is set to 1 but the on-chain resource is never updated, so the + // resource still believes ID 1 is free. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Loopback255".to_string(), + ip_net: "172.16.0.1/32".parse().unwrap(), + node_segment_idx: 1, + }), + // Only device + globalstate — no link_ips or segment_routing_ids accounts. + // This causes the processor to take the else branch and store node_segment_idx + // directly without updating the on-chain resource (accounts.len() == 4). + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Verify base state: loopback has node_segment_idx=1, no flex-algo segments yet. + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.node_segment_idx, 1, + "Base node_segment_idx should be 1" + ); + assert_eq!( + iface.flex_algo_node_segments.len(), + 0, + "No flex-algo segments before backfill" + ); + + // Step 8: Create topology + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Step 9: Call BackfillTopology. With the fix, the pre-mark pass marks ID 1 as used + // before allocating, so the flex-algo segment receives ID 2 (not 1). + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![AccountMeta::new(device_pubkey, false)]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: flex-algo segment has node_segment_idx=2, NOT 1 (which is the base idx). + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.node_segment_idx, 1, + "Base node_segment_idx must remain 1" + ); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after backfill" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the backfilled topology" + ); + assert_eq!( + iface.flex_algo_node_segments[0].node_segment_idx, 2, + "flex-algo node_segment_idx must be 2 (fresh allocation), not 1 (base)" + ); + + println!("[PASS] test_topology_backfill_avoids_collision_with_existing_node_segment_idx"); +} + // ============================================================================ // unicast_drained tests // ============================================================================ @@ -1915,7 +2173,7 @@ async fn test_link_unicast_drained_contributor_can_set_own_link() { // Verify unicast_drained starts as false let link = get_link(&mut banks_client, link_pubkey).await; - assert!(!link.unicast_drained); + assert!(!link.is_unicast_drained()); // Contributor A (payer) sets unicast_drained = true let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -1938,7 +2196,7 @@ async fn test_link_unicast_drained_contributor_can_set_own_link() { // Read back: unicast_drained must be true let link = get_link(&mut banks_client, link_pubkey).await; - assert!(link.unicast_drained); + assert!(link.is_unicast_drained()); println!("[PASS] test_link_unicast_drained_contributor_can_set_own_link"); } @@ -2058,7 +2316,7 @@ async fn test_link_unicast_drained_foundation_can_set_any_link() { .await; let link = get_link(&mut banks_client, link_pubkey).await; - assert!(link.unicast_drained); + assert!(link.is_unicast_drained()); println!("[PASS] test_link_unicast_drained_foundation_can_set_any_link"); } @@ -2113,7 +2371,7 @@ async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { let link_before = get_link(&mut banks_client, link_pubkey).await; assert!(link_before.link_topologies.contains(&topology_pda)); - assert!(!link_before.unicast_drained); + assert!(!link_before.is_unicast_drained()); // Set unicast_drained = true let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); @@ -2135,7 +2393,10 @@ async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { .await; let link_after = get_link(&mut banks_client, link_pubkey).await; - assert!(link_after.unicast_drained, "unicast_drained should be true"); + assert!( + link_after.is_unicast_drained(), + "unicast_drained should be true" + ); assert_eq!( link_after.status, link_before.status, "status should be unchanged" diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index bb1fa261a9..0bb16d2383 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -703,7 +703,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { jitter_ns: 10000, delay_override_ns: 0, link_type: LinkLinkType::WAN, - mtu: 9000, + mtu: 0, tunnel_id: 0, tunnel_net: NetworkV4::default(), side_a_iface_name: "Ethernet0".to_string(), @@ -711,7 +711,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { link_health: LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let mut data = Vec::new(); @@ -854,7 +854,7 @@ async fn test_initialize_device_latency_samples_fail_origin_device_not_activated code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -992,7 +992,7 @@ async fn test_initialize_device_latency_samples_fail_target_device_not_activated code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1128,7 +1128,7 @@ async fn test_initialize_device_latency_samples_success_provisioning_link() { code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1392,7 +1392,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_devices() { code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1528,7 +1528,7 @@ async fn test_initialize_device_latency_samples_succeeds_with_reversed_link_side code: "LINK1".into(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet1".to_string(), @@ -1861,7 +1861,7 @@ async fn test_initialize_device_latency_samples_fail_agent_not_owner_of_origin_d code: "LNK".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 9000, + mtu: 4500, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index 512926e5c9..2f8a6bbd03 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -76,8 +76,26 @@ func DeserializeContributor(reader *ByteReader, contributor *Contributor) { contributor.BumpSeed = reader.ReadU8() contributor.Status = ContributorStatus(reader.ReadU8()) contributor.Code = reader.ReadString() - contributor.ReferenceCount = reader.ReadU32() - contributor.OpsManagerPK = reader.ReadPubkey() + contributor.PubKey = reader.ReadPubkey() +} + +func DeserializeTenant(reader *ByteReader, tenant *Tenant) { + tenant.AccountType = AccountType(reader.ReadU8()) + tenant.Owner = reader.ReadPubkey() + tenant.BumpSeed = reader.ReadU8() + tenant.Code = reader.ReadString() + tenant.VrfId = reader.ReadU16() + tenant.ReferenceCount = reader.ReadU32() + tenant.Administrators = reader.ReadPubkeySlice() + tenant.PaymentStatus = TenantPaymentStatus(reader.ReadU8()) + tenant.TokenAccount = reader.ReadPubkey() + tenant.MetroRouting = (reader.ReadU8() != 0) + tenant.RouteLiveness = (reader.ReadU8() != 0) + tenant.BillingDiscriminant = reader.ReadU8() + tenant.BillingRate = reader.ReadU64() + tenant.BillingLastDeductionDzEpoch = reader.ReadU64() + tenant.IncludeTopologies = reader.ReadPubkeySlice() + // Note: tenant.PubKey is set separately in client.go after deserialization } func DeserializeInterface(reader *ByteReader, iface *Interface) { @@ -124,25 +142,16 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.IpNet = reader.ReadNetworkV4() iface.NodeSegmentIdx = reader.ReadU16() iface.UserTunnelEndpoint = (reader.ReadU8() != 0) + // flex_algo_node_segments was merged into V2 from the old V3. + // Old V2 accounts (written before this field existed) will have no trailing + // bytes — ReadFlexAlgoNodeSegmentSlice returns nil/empty in that case. + iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() } +// DeserializeInterfaceV3 handles legacy on-chain accounts written with +// discriminant 2 (the old V3). Their layout is identical to the current V2. func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { - iface.Status = InterfaceStatus(reader.ReadU8()) - iface.Name = reader.ReadString() - iface.InterfaceType = InterfaceType(reader.ReadU8()) - iface.InterfaceCYOA = InterfaceCYOA(reader.ReadU8()) - iface.InterfaceDIA = InterfaceDIA(reader.ReadU8()) - loopbackTypeByte := reader.ReadU8() - iface.LoopbackType = LoopbackType(loopbackTypeByte) - 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) - iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() + DeserializeInterfaceV2(reader, iface) } func DeserializeDevice(reader *ByteReader, dev *Device) { @@ -208,6 +217,8 @@ func DeserializeLink(reader *ByteReader, link *Link) { link.DelayOverrideNs = reader.ReadU64() link.LinkHealth = LinkHealth(reader.ReadU8()) link.LinkDesiredStatus = LinkDesiredStatus(reader.ReadU8()) + link.LinkTopologies = reader.ReadPubkeySlice() + link.LinkFlags = reader.ReadU8() } func DeserializeUser(reader *ByteReader, user *User) { @@ -373,3 +384,14 @@ func DeserializePermission(reader *ByteReader, perm *Permission) { perm.PermissionsLo = reader.ReadU64() // bits 0-63 (low u64 of u128) perm.PermissionsHi = reader.ReadU64() // bits 64-127 (high u64 of u128) } + +func DeserializeTopologyInfo(reader *ByteReader, t *TopologyInfo) { + t.AccountType = AccountType(reader.ReadU8()) + t.Owner = reader.ReadPubkey() + t.BumpSeed = reader.ReadU8() + t.Name = reader.ReadString() + t.AdminGroupBit = reader.ReadU8() + t.FlexAlgoNumber = reader.ReadU8() + t.Constraint = TopologyConstraint(reader.ReadU8()) + // Note: t.PubKey is set from the account address in client.go after deserialization +} diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 67eddc17ed..eea9e19f30 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -26,6 +26,7 @@ const ( TenantType // 13 // 14 is reserved PermissionType AccountType = 15 + TopologyType AccountType = 16 ) type LocationStatus uint8 @@ -386,7 +387,7 @@ func (l RoutingMode) MarshalJSON() ([]byte, error) { } type FlexAlgoNodeSegment struct { - Topology [32]uint8 + Topology [32]byte NodeSegmentIdx uint16 } @@ -404,9 +405,9 @@ type Interface struct { RoutingMode RoutingMode VlanId uint16 IpNet [5]uint8 - NodeSegmentIdx uint16 - UserTunnelEndpoint bool - FlexAlgoNodeSegments []FlexAlgoNodeSegment + NodeSegmentIdx uint16 + UserTunnelEndpoint bool + FlexAlgoNodeSegments []FlexAlgoNodeSegment `json:",omitempty"` } func (i Interface) MarshalJSON() ([]byte, error) { @@ -651,6 +652,8 @@ type Link struct { LinkHealth LinkHealth `influx:"field,link_health"` LinkDesiredStatus LinkDesiredStatus `influx:"tag,link_desired_status"` PubKey [32]byte `influx:"tag,pubkey,pubkey"` + LinkTopologies [][32]byte + LinkFlags uint8 } func (l Link) MarshalJSON() ([]byte, error) { @@ -778,6 +781,7 @@ type Tenant struct { BillingRate uint64 `influx:"field,billing_rate"` BillingLastDeductionDzEpoch uint64 `influx:"field,billing_last_deduction_dz_epoch"` PubKey [32]byte `influx:"tag,pubkey,pubkey"` + IncludeTopologies [][32]byte } func (t Tenant) MarshalJSON() ([]byte, error) { @@ -1290,3 +1294,32 @@ func (r ResourceExtension) MarshalJSON() ([]byte, error) { return json.Marshal(jsonExt) } + +type TopologyConstraint uint8 + +const ( + TopologyConstraintIncludeAny TopologyConstraint = 0 + TopologyConstraintExclude TopologyConstraint = 1 +) + +func (c TopologyConstraint) String() string { + switch c { + case TopologyConstraintIncludeAny: + return "include-any" + case TopologyConstraintExclude: + return "exclude" + default: + return "unknown" + } +} + +type TopologyInfo struct { + AccountType AccountType + Owner [32]byte + BumpSeed uint8 + Name string + AdminGroupBit uint8 + FlexAlgoNumber uint8 + Constraint TopologyConstraint + PubKey [32]byte +} diff --git a/smartcontract/sdk/go/serviceability/state_test.go b/smartcontract/sdk/go/serviceability/state_test.go index c69646ae3f..15d29cebd7 100644 --- a/smartcontract/sdk/go/serviceability/state_test.go +++ b/smartcontract/sdk/go/serviceability/state_test.go @@ -71,6 +71,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "Switch1/1/1", "SideZIfaceName": "Switch1/1/1", "DelayOverrideNs": 10, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, @@ -122,6 +124,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "Edge1/0/0", "SideZIfaceName": "Edge2/0/0", "DelayOverrideNs": 0, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, @@ -153,6 +157,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "", "SideZIfaceName": "", "DelayOverrideNs": 0, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "11111111111111111111111111111111" }`, expectErr: false, @@ -304,7 +310,7 @@ func TestCustomJSONMarshal(t *testing.T) { "MgmtVrf": "mgmt-vrf", "Interfaces": [ { - "Version": 1, + "Version": 2, "Status": "activated", "Name": "Switch1/1/1", "InterfaceType": "physical", @@ -414,6 +420,7 @@ func TestCustomJSONMarshal(t *testing.T) { "BillingDiscriminant": 0, "BillingRate": 0, "BillingLastDeductionDzEpoch": 0, + "IncludeTopologies": null, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, diff --git a/smartcontract/sdk/rs/src/commands/contributor/create.rs b/smartcontract/sdk/rs/src/commands/contributor/create.rs index 229f0fddcf..257d0e2afb 100644 --- a/smartcontract/sdk/rs/src/commands/contributor/create.rs +++ b/smartcontract/sdk/rs/src/commands/contributor/create.rs @@ -30,6 +30,7 @@ impl CreateContributorCommand { AccountMeta::new(pda_pubkey, false), AccountMeta::new(self.owner, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(client.get_payer(), true), // TEMP: workaround for --owner me payer bug ], ) .map(|sig| (sig, pda_pubkey)) diff --git a/smartcontract/sdk/rs/src/commands/link/accept.rs b/smartcontract/sdk/rs/src/commands/link/accept.rs index 565be69caf..387bde3d34 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -136,7 +136,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let device_z = doublezero_serviceability::state::device::Device { @@ -240,7 +240,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let device_z = doublezero_serviceability::state::device::Device { diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index 48ad41e7fa..aa2a4c4b73 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -138,7 +138,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Mock Link fetch @@ -213,7 +213,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs index 4a90b74dcc..a6424c700b 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -117,7 +117,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Mock Link fetch @@ -190,7 +190,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/delete.rs b/smartcontract/sdk/rs/src/commands/link/delete.rs index 98c0ef2cbb..979ba522b6 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -108,7 +108,7 @@ mod tests { desired_status: LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, } } diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs index c8efc3668e..f63344072b 100644 --- a/smartcontract/sdk/rs/src/commands/topology/create.rs +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -24,6 +24,15 @@ impl CreateTopologyCommand { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + // Pre-flight: verify admin-group-bits resource account exists + client.get_account(admin_group_bits_pda).map_err(|_| { + eyre::eyre!( + "admin-group-bits resource account not found ({}). \ + Run 'doublezero resource create --resource-type admin-group-bits' first.", + admin_group_bits_pda + ) + })?; + client .execute_transaction( DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { @@ -54,7 +63,7 @@ mod tests { state::topology::TopologyConstraint, }; use mockall::predicate; - use solana_sdk::{instruction::AccountMeta, signature::Signature}; + use solana_sdk::{account::Account, instruction::AccountMeta, signature::Signature}; #[test] fn test_commands_topology_create_command() { @@ -65,6 +74,11 @@ mod tests { let (admin_group_bits_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + client + .expect_get_account() + .with(predicate::eq(admin_group_bits_pda)) + .returning(|_| Ok(Account::default())); + client .expect_execute_transaction() .with( diff --git a/smartcontract/test/start-test.sh b/smartcontract/test/start-test.sh index 12308ff925..1645adb200 100644 --- a/smartcontract/test/start-test.sh +++ b/smartcontract/test/start-test.sh @@ -12,22 +12,18 @@ mkdir -p ./logs ./target export OPENSSL_NO_VENDOR=1 -if [ "${CARGO_TARGET_DIR}" == "" ]; then - CARGO_TARGET_DIR="../../target" -fi - # Build the program echo "Build the program" -cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ${CARGO_TARGET_DIR} -cp ${CARGO_TARGET_DIR}/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so +cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ../../target/ +cp ../../target/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so #Build the activator echo "Build the activator" -cargo build --manifest-path ../../activator/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero-activator ./target/ +cargo build --manifest-path ../../activator/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero-activator ./target/ #Build the activator echo "Build the client" -cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero ./target/ +cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero ./target/ # Configure to connect to localnet solana config set --url http://127.0.0.1:8899 @@ -129,24 +125,24 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w ### Initialize links echo "Creating internal links" @@ -168,7 +164,7 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 1500 --routing-mode static -w ### Initialize links echo "Creating external links" From beb185296d9f58de79d1978838ef6c19e276d411 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:33:09 -0500 Subject: [PATCH 34/54] smartcontract: add topology account validation and cap enforcement to update_link; add topology tests --- .../src/processors/link/update.rs | 24 + .../tests/link_wan_test.rs | 620 ++++++++++++++++++ 2 files changed, 644 insertions(+) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 5119fe68ed..00f26c5455 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -13,6 +13,7 @@ use crate::{ feature_flags::{is_feature_enabled, FeatureFlag}, globalstate::GlobalState, link::*, + topology::TopologyInfo, }, }; use borsh::BorshSerialize; @@ -121,6 +122,11 @@ pub fn process_update_link( if value.use_onchain_allocation { expected_without_side_z += 2; // device_tunnel_block, link_ids } + // Topology accounts are passed as trailing accounts after system_program. + // Include them in the expected count so side_z detection is not confused. + if let Some(ref link_topologies) = value.link_topologies { + expected_without_side_z += link_topologies.len(); + } let side_z_account: Option<&AccountInfo> = if accounts.len() > expected_without_side_z { Some(next_account_info(accounts_iter)?) } else { @@ -146,6 +152,7 @@ pub fn process_update_link( let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; + let topology_accounts: Vec<&AccountInfo> = accounts_iter.collect(); #[cfg(test)] msg!("process_update_link({:?})", value); @@ -376,6 +383,23 @@ pub fn process_update_link( msg!("link_topologies update requires foundation allowlist"); return Err(DoubleZeroError::NotAllowed.into()); } + if link_topologies.len() > 8 { + msg!("link_topologies exceeds maximum of 8 entries"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + if link_topologies.len() != topology_accounts.len() { + msg!("link_topologies count does not match provided topology accounts"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + for (pk, acc) in link_topologies.iter().zip(topology_accounts.iter()) { + if acc.key != pk || acc.owner != program_id || acc.data_is_empty() { + return Err(DoubleZeroError::InvalidArgument.into()); + } + TopologyInfo::try_from(*acc) + .map_err(|_| DoubleZeroError::InvalidAccountType)? + .validate() + .map_err(ProgramError::from)?; + } link.link_topologies = link_topologies.clone(); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 00d313c561..0f6934cb03 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2718,3 +2718,623 @@ async fn test_link_activation_fails_without_unicast_default() { error_string ); } +} + +#[tokio::test] +async fn test_link_topology_cap_at_8_rejected() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Attempt to set 9 topology pubkeys — exceeds cap of 8 + let nine_pubkeys: Vec = (0..9).map(|_| Pubkey::new_unique()).collect(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(nine_pubkeys), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", + error_string + ); +} + +#[tokio::test] +async fn test_link_topology_invalid_account_rejected() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + _device_a_pubkey, + _device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(_device_a_pubkey, false), + AccountMeta::new(_device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + // Pass a bogus pubkey that has no onchain data — data_is_empty() → InvalidArgument + let bogus_pubkey = Pubkey::new_unique(); + let result = try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(vec![bogus_pubkey]), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(bogus_pubkey, false)], + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", + error_string + ); +} + +#[tokio::test] +async fn test_link_topology_valid_accepted() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create a second topology to assign to the link + let (topo_a_pda, _) = get_topology_pda(&program_id, "topo-a"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "topo-a".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topo_a_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Assign the topology to the link — should succeed + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(vec![topo_a_pda]), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(topo_a_pda, false)], + ) + .await + .expect("Setting valid topology on link should succeed"); +} + +#[tokio::test] +async fn test_link_create_invalid_mtu() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + _tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Create link with MTU 1500 (should fail, must be 9000) + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (tunnel_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); + + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLink(LinkCreateArgs { + code: "invalid-mtu".to_string(), + link_type: LinkLinkType::WAN, + bandwidth: 20000000000, + mtu: 1500, + delay_ns: 1000000, + jitter_ns: 100000, + side_a_iface_name: "Ethernet0".to_string(), + side_z_iface_name: Some("Ethernet1".to_string()), + desired_status: Some(LinkDesiredStatus::Activated), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", res.unwrap_err()); + assert!( + error_string.contains("Custom(46)"), + "Expected InvalidMtu error (Custom(46)), got: {}", + error_string + ); +} + +// ─── link_topologies update tests ──────────────────────────────────────────── + +/// Foundation key can reassign link_topologies to a different topology after +/// activation, overriding the auto-tag set by ActivateLink. +#[tokio::test] +async fn test_link_topology_reassigned_by_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create unicast-default topology (required for activation) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + // Create a second topology: high-bandwidth + let (high_bandwidth_pda, _) = get_topology_pda(&program_id, "high-bandwidth"); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "high-bandwidth".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(high_bandwidth_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Activate — auto-tags with unicast-default + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.link_topologies, vec![unicast_default_pda]); + + // Foundation reassigns link_topologies to high-bandwidth + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![high_bandwidth_pda]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(_contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(high_bandwidth_pda, false)], + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!( + link.link_topologies, + vec![high_bandwidth_pda], + "link_topologies should be updated to high-bandwidth PDA" + ); +} + +/// Foundation key can clear link_topologies to an empty vector, removing the +/// link from all constrained topologies (multicast-only link case). +#[tokio::test] +async fn test_link_topology_cleared_by_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + // Activate — auto-tags with unicast-default + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.link_topologies, vec![unicast_default_pda]); + + // Foundation clears link_topologies — link becomes multicast-only + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!( + link.link_topologies, + vec![], + "link_topologies should be empty after clearing" + ); +} + +/// A non-foundation payer cannot set link_topologies — the instruction must +/// be rejected with NotAllowed (Custom(8)). +#[tokio::test] +async fn test_link_topology_update_rejected_for_non_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Create a non-foundation keypair and fund it + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 1_000_000_000, + ) + .await; + + // Non-foundation payer attempts to set link_topologies on the existing link. + // The outer ownership check fails because the payer is neither the + // contributor's owner nor in the foundation allowlist. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![unicast_default_pda]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(8)"), + "Expected NotAllowed error (Custom(8)), got: {}", + error_string + ); +} From 66c14356744bad9bcdde08bc8a10e2623dde9f90 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 14:39:16 -0500 Subject: [PATCH 35/54] smartcontract: fix mtu values in link_wan_test fixtures; add topology imports --- .../tests/link_wan_test.rs | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 0f6934cb03..bd04cabb4f 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -5,6 +5,7 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::interface::{update::DeviceInterfaceUpdateArgs, DeviceInterfaceUnlinkArgs}, link::{activate::*, create::*, delete::*, sethealth::LinkSetHealthArgs, update::*}, + topology::create::TopologyCreateArgs, *, }, resource::ResourceType, @@ -16,6 +17,7 @@ use doublezero_serviceability::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, link::*, + topology::TopologyConstraint, }, }; use globalconfig::set::SetGlobalConfigArgs; @@ -231,7 +233,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -362,7 +364,7 @@ async fn test_wan_link() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -610,7 +612,7 @@ async fn test_wan_link() { contributor_pk: Some(contributor_pubkey), tunnel_type: Some(LinkLinkType::WAN), bandwidth: Some(20000000000), - mtu: Some(8900), + mtu: Some(9000), delay_ns: Some(1000000), jitter_ns: Some(100000), delay_override_ns: Some(0), @@ -639,7 +641,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 8900); + assert_eq!(tunnel_la.mtu, 9000); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Activated); assert_eq!(tunnel_la.desired_status, LinkDesiredStatus::Activated); @@ -823,7 +825,7 @@ async fn test_wan_link() { assert_eq!(tunnel_la.account_type, AccountType::Link); assert_eq!(tunnel_la.code, "la2".to_string()); assert_eq!(tunnel_la.bandwidth, 20000000000); - assert_eq!(tunnel_la.mtu, 8900); + assert_eq!(tunnel_la.mtu, 9000); assert_eq!(tunnel_la.delay_ns, 1000000); assert_eq!(tunnel_la.status, LinkStatus::Deleting); @@ -1056,7 +1058,7 @@ async fn test_wan_link_rejects_cyoa_interface() { bandwidth: 0, ip_net: Some("100.1.0.0/31".parse().unwrap()), cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1149,7 +1151,7 @@ async fn test_wan_link_rejects_cyoa_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1249,7 +1251,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(9000), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1277,7 +1279,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(1500), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1341,7 +1343,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(9000), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1398,7 +1400,7 @@ async fn test_wan_link_rejects_cyoa_interface() { loopback_type: None, bandwidth: None, cir: None, - mtu: None, + mtu: Some(1500), routing_mode: None, vlan_id: None, user_tunnel_endpoint: None, @@ -1623,7 +1625,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1714,7 +1716,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2088,7 +2090,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2176,7 +2178,7 @@ async fn setup_link_env() -> ( bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2718,7 +2720,6 @@ async fn test_link_activation_fails_without_unicast_default() { error_string ); } -} #[tokio::test] async fn test_link_topology_cap_at_8_rejected() { From a6e3a59dd2e81cb922d9aeb713c5f79ad469fa87 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 15:50:28 -0500 Subject: [PATCH 36/54] sdk/go: fix parse_valid_link test and controller findLink after LinkTopologies addition --- .../controller/internal/controller/server.go | 12 ++++++------ smartcontract/sdk/go/serviceability/client_test.go | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/controlplane/controller/internal/controller/server.go b/controlplane/controller/internal/controller/server.go index 07298c7877..bc4eabcdff 100644 --- a/controlplane/controller/internal/controller/server.go +++ b/controlplane/controller/internal/controller/server.go @@ -348,22 +348,22 @@ func (c *Controller) updateStateCache(ctx context.Context) error { cache.Ipv4BgpPeers = append(cache.Ipv4BgpPeers, candidateIpv4BgpPeer) // determine if interface is in an onchain link and assign metrics - findLink := func(intf Interface) serviceability.Link { - for _, link := range links { + findLink := func(intf Interface) *serviceability.Link { + for i, link := range links { if d.PubKey == base58.Encode(link.SideAPubKey[:]) && intf.Name == link.SideAIfaceName { - return link + return &links[i] } if d.PubKey == base58.Encode(link.SideZPubKey[:]) && intf.Name == link.SideZIfaceName { - return link + return &links[i] } } - return serviceability.Link{} + return nil } for i, iface := range d.Interfaces { link := findLink(iface) - if link == (serviceability.Link{}) || (link.Status != serviceability.LinkStatusActivated && link.Status != serviceability.LinkStatusSoftDrained && link.Status != serviceability.LinkStatusHardDrained) { + if link == nil || (link.Status != serviceability.LinkStatusActivated && link.Status != serviceability.LinkStatusSoftDrained && link.Status != serviceability.LinkStatusHardDrained) { d.Interfaces[i].IsLink = false d.Interfaces[i].Metric = 0 d.Interfaces[i].LinkStatus = serviceability.LinkStatusPending diff --git a/smartcontract/sdk/go/serviceability/client_test.go b/smartcontract/sdk/go/serviceability/client_test.go index f52c338379..052e0893be 100644 --- a/smartcontract/sdk/go/serviceability/client_test.go +++ b/smartcontract/sdk/go/serviceability/client_test.go @@ -403,6 +403,8 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { SideZIfaceName: "lo0", LinkHealth: LinkHealth(173), LinkDesiredStatus: LinkDesiredStatus(37), + LinkFlags: 118, + LinkTopologies: nil, PubKey: pubkeys[5], }, }, From ab98df599715f26f1e74e728e917092ff7589a06 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 16:47:16 -0500 Subject: [PATCH 37/54] smartcontract: restore start-test.sh to match main --- smartcontract/test/start-test.sh | 50 +++++++++++++++++--------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/smartcontract/test/start-test.sh b/smartcontract/test/start-test.sh index 1645adb200..12308ff925 100644 --- a/smartcontract/test/start-test.sh +++ b/smartcontract/test/start-test.sh @@ -12,18 +12,22 @@ mkdir -p ./logs ./target export OPENSSL_NO_VENDOR=1 +if [ "${CARGO_TARGET_DIR}" == "" ]; then + CARGO_TARGET_DIR="../../target" +fi + # Build the program echo "Build the program" -cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ../../target/ -cp ../../target/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so +cargo build-sbf --manifest-path ../programs/doublezero-serviceability/Cargo.toml -- -Znext-lockfile-bump --target-dir ${CARGO_TARGET_DIR} +cp ${CARGO_TARGET_DIR}/deploy/doublezero_serviceability.so ./target/doublezero_serviceability.so #Build the activator echo "Build the activator" -cargo build --manifest-path ../../activator/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero-activator ./target/ +cargo build --manifest-path ../../activator/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero-activator ./target/ #Build the activator echo "Build the client" -cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ../../target/ ; cp ../../target/debug/doublezero ./target/ +cargo build --manifest-path ../../client/doublezero/Cargo.toml --target-dir ${CARGO_TARGET_DIR} ; cp ${CARGO_TARGET_DIR}/debug/doublezero ./target/ # Configure to connect to localnet solana config set --url http://127.0.0.1:8899 @@ -125,24 +129,24 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w -./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create la2-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ny5-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ld4-dz01 "Switch1/1/3" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create frk-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create sg1-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ty2-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create pit-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/1" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w +./target/doublezero device interface create ams-dz01 "Switch1/1/2" --bandwidth "10 Gbps" --cir "10 Gbps" --mtu 9000 --routing-mode static -w ### Initialize links echo "Creating internal links" @@ -164,7 +168,7 @@ echo "Creating devices" ### Initialize device interfaces echo "Creating device interfaces" -./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 1500 --routing-mode static -w +./target/doublezero device interface create la2-dz02 "Switch1/1/1" --bandwidth "1G" --cir "1G" --mtu 9000 --routing-mode static -w ### Initialize links echo "Creating external links" From f828206fb2c6776313dcf9f9f6d64f0577ee65d2 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 22:31:17 -0500 Subject: [PATCH 38/54] smartcontract: fix post-rebase compile errors in cli link commands and activator tests --- activator/src/processor.rs | 4 +- smartcontract/cli/src/link/dzx_create.rs | 37 +++++++------ smartcontract/cli/src/link/update.rs | 54 ++++++++++--------- smartcontract/cli/src/link/wan_create.rs | 43 ++++++++------- .../src/state/interface.rs | 4 ++ 5 files changed, 78 insertions(+), 64 deletions(-) diff --git a/activator/src/processor.rs b/activator/src/processor.rs index ba6fee1ed5..b9c106f933 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -766,7 +766,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let mut existing_links: HashMap = HashMap::new(); @@ -803,7 +803,7 @@ mod tests { desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, link_topologies: Vec::new(), - unicast_drained: false, + link_flags: 0, }; let new_link_cloned = new_link.clone(); diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index 252b228fb4..d3c17a8cd0 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_parse_mtu, validate_pubkey_or_code, + validate_pubkey_or_code, }, }; use clap::Args; @@ -48,8 +48,8 @@ pub struct CreateDZXLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. - #[arg(long, value_parser = validate_parse_mtu)] + /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. + #[arg(long, default_value_t = 9000)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -126,13 +126,17 @@ impl CreateDZXLinkCliCommand { )); } - if side_a_iface.mtu != 2048 { + if side_a_iface.mtu != 9000 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 2048", + "Interface '{}' on side A device has MTU {} but DZX link interfaces must have MTU 9000", self.side_a_interface, side_a_iface.mtu )); } + if self.mtu != 9000 { + return Err(eyre!("Link MTU must be 9000")); + } + if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -230,7 +234,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -275,7 +279,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -320,7 +324,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -346,7 +350,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -359,8 +363,7 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -403,7 +406,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::DZX, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -421,7 +424,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -449,7 +452,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -552,7 +555,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -653,7 +656,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 2048, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -664,7 +667,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 2048" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but DZX link interfaces must have MTU 9000" ); } } diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index 9e25fe3769..e5ea6785a7 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -4,8 +4,8 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, - validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_parse_mtu, - validate_pubkey, validate_pubkey_or_code, + validate_parse_delay_override_ms, validate_parse_jitter_ms, validate_pubkey, + validate_pubkey_or_code, }, }; use clap::Args; @@ -13,6 +13,7 @@ use doublezero_program_common::types::NetworkV4; use doublezero_sdk::commands::{ contributor::get::GetContributorCommand, link::{get::GetLinkCommand, update::UpdateLinkCommand}, + topology::list::ListTopologyCommand, }; use doublezero_serviceability::state::link::LinkDesiredStatus; use eyre::eyre; @@ -35,8 +36,8 @@ pub struct UpdateLinkCliCommand { /// Updated bandwidth (e.g. 1Gbps, 100Mbps) #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: Option, - /// Updated MTU (Maximum Transmission Unit) in bytes - #[arg(long, value_parser = validate_parse_mtu)] + /// Updated MTU (Maximum Transmission Unit) in bytes. Must be 9000. + #[arg(long)] pub mtu: Option, /// RTT (Round Trip Time) delay in milliseconds #[arg(long, value_parser = validate_parse_delay_ms)] @@ -103,6 +104,12 @@ impl UpdateLinkCliCommand { .transpose() .map_err(|e| eyre!("Invalid status: {e}"))?; + if let Some(mtu) = self.mtu { + if mtu != 9000 { + return Err(eyre!("Link MTU must be 9000")); + } + } + if let Some(ref code) = self.code { if link.code != *code && client @@ -115,19 +122,18 @@ impl UpdateLinkCliCommand { } } - let link_topologies = if let Some(ref topology_name) = self.link_topology { - if topology_name == "default" { - Some(vec![]) - } else { - let (topology_pda, _) = - doublezero_sdk::get_topology_pda(&client.get_program_id(), topology_name); - client - .get_account(topology_pda) - .map_err(|_| eyre::eyre!("Topology '{}' not found", topology_name))?; - Some(vec![topology_pda]) + let link_topologies = match self.link_topology { + None => None, + Some(ref name) if name == "default" => Some(vec![]), + Some(ref name) => { + let topology_map = client.list_topology(ListTopologyCommand)?; + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre!("Topology '{}' not found", name))?; + Some(vec![topology_pk]) } - } else { - None }; let signature = client.update_link(UpdateLinkCommand { @@ -217,7 +223,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -229,8 +235,7 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -244,7 +249,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -256,8 +261,7 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -297,7 +301,7 @@ mod tests { contributor_pk: Some(contributor_pk), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(1500), + mtu: Some(9000), delay_ns: Some(10000000), jitter_ns: Some(5000000), delay_override_ns: None, @@ -318,7 +322,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(1500), + mtu: Some(9000), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, @@ -345,7 +349,7 @@ mod tests { contributor: Some(contributor_pk.to_string()), tunnel_type: None, bandwidth: Some(1000000000), - mtu: Some(1500), + mtu: Some(9000), delay_ms: Some(10.0), jitter_ms: Some(5.0), delay_override_ms: None, diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index a443f626a8..fc09b3b10b 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -5,7 +5,7 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, validators::{ validate_code, validate_parse_bandwidth, validate_parse_delay_ms, validate_parse_jitter_ms, - validate_parse_mtu, validate_pubkey_or_code, + validate_pubkey_or_code, }, }; use clap::Args; @@ -51,8 +51,8 @@ pub struct CreateWANLinkCliCommand { /// Bandwidth (required). Accepts values in Kbps, Mbps, or Gbps. #[arg(long, value_parser = validate_parse_bandwidth)] pub bandwidth: u64, - /// MTU (Maximum Transmission Unit) in bytes. - #[arg(long, value_parser = validate_parse_mtu)] + /// MTU (Maximum Transmission Unit) in bytes. Must be 9000. + #[arg(long, default_value_t = 9000)] pub mtu: u32, /// RTT (Round Trip Time) delay in milliseconds. #[arg(long, value_parser = validate_parse_delay_ms)] @@ -129,9 +129,9 @@ impl CreateWANLinkCliCommand { )); } - if side_a_iface.mtu != 2048 { + if side_a_iface.mtu != 9000 { return Err(eyre!( - "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 2048", + "Interface '{}' on side A device has MTU {} but WAN link interfaces must have MTU 9000", self.side_a_interface, side_a_iface.mtu )); } @@ -171,13 +171,17 @@ impl CreateWANLinkCliCommand { )); } - if side_z_iface.mtu != 2048 { + if side_z_iface.mtu != 9000 { return Err(eyre!( - "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 2048", + "Interface '{}' on side Z device has MTU {} but WAN link interfaces must have MTU 9000", self.side_z_interface, side_z_iface.mtu )); } + if self.mtu != 9000 { + return Err(eyre!("Link MTU must be 9000")); + } + if client .get_link(GetLinkCommand { pubkey_or_code: self.code.clone(), @@ -277,7 +281,7 @@ mod tests { ip_net: "10.2.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -322,7 +326,7 @@ mod tests { ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -367,7 +371,7 @@ mod tests { ip_net: "10.2.0.3/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, - mtu: 2048, + mtu: 9000, ..Default::default() } .to_interface()], @@ -393,7 +397,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -406,8 +410,7 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, - - link_topologies: Vec::new(), + link_topologies: vec![], link_flags: 0, }; @@ -450,7 +453,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ns: 10000000000, jitter_ns: 5000000000, side_a_iface_name: "Ethernet1/1".to_string(), @@ -468,7 +471,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -497,7 +500,7 @@ mod tests { side_a: device2_pk.to_string(), side_z: device3_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/2".to_string(), @@ -635,7 +638,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 1500, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -723,7 +726,7 @@ mod tests { name: "Ethernet1/2".to_string(), interface_type: InterfaceType::Physical, loopback_type: LoopbackType::None, - mtu: 2048, + mtu: 9000, vlan_id: 16, ip_net: "10.2.0.2/24".parse().unwrap(), node_segment_idx: 0, @@ -770,7 +773,7 @@ mod tests { side_a: device1_pk.to_string(), side_z: device2_pk.to_string(), bandwidth: 1000000000, - mtu: 2048, + mtu: 9000, delay_ms: 10000.0, jitter_ms: 5000.0, side_a_interface: "Ethernet1/1".to_string(), @@ -782,7 +785,7 @@ mod tests { assert!(res.is_err()); assert_eq!( res.unwrap_err().to_string(), - "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 2048" + "Interface 'Ethernet1/1' on side A device has MTU 1500 but WAN link interfaces must have MTU 9000" ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index c78143b6ca..5fc3613578 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -4,6 +4,10 @@ use doublezero_program_common::{types::NetworkV4, validate_iface}; use solana_program::{msg, program_error::ProgramError}; use std::{fmt, str::FromStr}; +pub const LINK_MTU: u32 = 9000; +pub const INTERFACE_MTU: u16 = 9000; +pub const CYOA_DIA_INTERFACE_MTU: u16 = 1500; + #[repr(u8)] #[derive(BorshSerialize, BorshDeserialize, Debug, Copy, Clone, PartialEq, Default)] #[borsh(use_discriminant = true)] From cdc1abe4733f67cbf077604e0fff5177f5f3823d Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Mon, 6 Apr 2026 23:00:36 -0500 Subject: [PATCH 39/54] smartcontract: add RFC-18 flex-algo CHANGELOG entries --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 891c326d8c..b9f38d4e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,19 @@ All notable changes to this project will be documented in this file. - Client - Add `doublezero_connection_info` Prometheus metric exposing connection metadata (user_type, network, current_device, metro, tunnel_name, tunnel_src, tunnel_dst) ([#3201](https://github.com/malbeclabs/doublezero/pull/3201)) - Add `doublezero_connection_rtt_nanoseconds` and `doublezero_connection_loss_percentage` Prometheus metrics reporting RTT and packet loss to the current connected device +- Smartcontract + - Add `TopologyInfo` onchain account for IS-IS flex-algo link classification: auto-assigned TE admin-group bit (1–62), derived flex-algo number (128 + bit), and constraint type (`include-any`/`include-all`); capped at 62 topologies via `AdminGroupBits` resource extension + - Add `link_topologies: Vec` (capped at 8) and `link_flags: u8` (bit 0 = unicast-drained) to the `Link` account + - Add `include_topologies` to the `Tenant` account for topology-filtered routing opt-in + - Enforce UNICAST-DEFAULT topology existence as a precondition for link activation +- CLI + - Add `doublezero link topology` subcommands: `create`, `delete`, `clear`, `list`, `backfill` + - Add `--link-topology ` and `--unicast-drained ` flags to `doublezero link update` + - Add `--topology ` filter to `doublezero link list` (`default` = untagged links) + - Add `--include-topologies ` flag to `doublezero tenant update` + - Add `doublezero-admin migrate flex-algo [--dry-run]` to tag existing links with UNICAST-DEFAULT and backfill node segments +- SDK + - Update Go, Python, and TypeScript SDKs with `TopologyInfo` deserialization and new `link_topologies`, `link_flags`, and `include_topologies` fields ## [v0.15.0](https://github.com/malbeclabs/doublezero/compare/client/v0.14.0...client/v0.15.0) - 2026-03-27 From a75bc5515b3c3ec4a19908b1c1bfb8ea8662c6ab Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 10:29:28 -0500 Subject: [PATCH 40/54] smartcontract: fix incorrect MTU in test_update_cyoa_interface_with_invalid_sibling --- .../tests/delete_cyoa_interface_test.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs index 376096ca18..b570eee720 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs @@ -414,7 +414,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { program_id, DoubleZeroInstruction::UpdateDeviceInterface(DeviceInterfaceUpdateArgs { name: "ethernet1".to_string(), - mtu: Some(9000), + mtu: Some(1500), // CYOA interfaces require CYOA_DIA_INTERFACE_MTU = 1500 ..Default::default() }), vec![ @@ -438,7 +438,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { .unwrap(); let updated_iface = device.find_interface("Ethernet1").unwrap().1; - assert_eq!(updated_iface.mtu, 9000, "MTU should be updated to 9000"); + assert_eq!(updated_iface.mtu, 1500, "MTU should remain 1500 for CYOA interface"); assert_eq!( updated_iface.ip_net, "63.243.225.62/30".parse().unwrap(), From a91771280a1d5afabbda4e98db3a183c0446c9ba Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 11:22:19 -0500 Subject: [PATCH 41/54] sdk/ts: fix deserializeInterface to read flex_algo_node_segments for V2 (version 1); rustfmt --- .../typescript/serviceability/state.ts | 20 +++++++++---------- .../src/instructions.rs | 4 ++-- .../doublezero-serviceability/src/pda.rs | 9 ++++----- .../tests/delete_cyoa_interface_test.rs | 5 ++++- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index 1b417a1cd2..cc655df412 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -547,18 +547,16 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - if (iface.version === 2) { - // V3 - const segCount = r.readU32(); - const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; - for (let i = 0; i < segCount; i++) { - flexAlgoNodeSegments.push({ - topology: readPubkey(r), - nodeSegmentIdx: r.readU16(), - }); - } - iface.flexAlgoNodeSegments = flexAlgoNodeSegments; + // flex_algo_node_segments is part of V2 (version byte 1) and later + const segCount = r.readU32(); + const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; + for (let i = 0; i < segCount; i++) { + flexAlgoNodeSegments.push({ + topology: readPubkey(r), + nodeSegmentIdx: r.readU16(), + }); } + iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } return iface; diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 944ffde61b..38a0081c21 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -510,7 +510,7 @@ impl DoubleZeroInstruction { Self::SetUserBGPStatus(_) => "SetUserBGPStatus".to_string(), // variant 106 Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 - Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 110 } } @@ -644,7 +644,7 @@ impl DoubleZeroInstruction { Self::SetUserBGPStatus(args) => format!("{args:?}"), // variant 106 Self::CreateTopology(args) => format!("{args:?}"), // variant 107 Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 - Self::ClearTopology(args) => format!("{args:?}"), // variant 109 + Self::ClearTopology(args) => format!("{args:?}"), // variant 109 Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 } } diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index eb90cd03c5..51b058f85d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -6,11 +6,10 @@ use crate::{ seeds::{ SEED_ACCESS_PASS, SEED_ADMIN_GROUP_BITS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, - SEED_INDEX, SEED_LINK, - SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, - SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, - SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, SEED_USER, - SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, + SEED_INDEX, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, + SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, + SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, + SEED_USER, SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, }, state::user::UserType, }; diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs index b570eee720..08baafaed7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs @@ -438,7 +438,10 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { .unwrap(); let updated_iface = device.find_interface("Ethernet1").unwrap().1; - assert_eq!(updated_iface.mtu, 1500, "MTU should remain 1500 for CYOA interface"); + assert_eq!( + updated_iface.mtu, 1500, + "MTU should remain 1500 for CYOA interface" + ); assert_eq!( updated_iface.ip_net, "63.243.225.62/30".parse().unwrap(), From d8f3300a16a7b0a47c5396d695c07a2e57deb7f5 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 12:39:05 -0500 Subject: [PATCH 42/54] e2e/smartcontract: fix CI failures on PR #3474 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - interface_test.rs: add admin_group_bits_pda to SetGlobalConfig accounts in test_interface_create_invalid_mtu_{non_cyoa,cyoa}; RFC-18 added AdminGroupBits as a required account for SetGlobalConfig - sdk/go/serviceability/state.go: fix gofmt formatting violation - sdk/rs/client.rs: fix payer deduplication when user_payer==payer; check `a.is_signer` before skipping payer insertion so the payer is added as a signer even when already present as a non-signer - e2e/compatibility_test.go: add testnet envOverride for all interface and link steps covering v0.10.0–v0.16.x (testnet v0.10.0–v0.11.0 predate the commands; testnet v0.16.0 was built before RFC-18 InterfaceV2 change); create unicast-default topology in compat test setup so the activator can activate links (required by RFC-18) --- e2e/compatibility_test.go | 63 +++++++++++-------- .../tests/interface_test.rs | 6 ++ smartcontract/sdk/go/serviceability/state.go | 26 ++++---- smartcontract/sdk/rs/src/client.rs | 2 +- 4 files changed, 58 insertions(+), 39 deletions(-) diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go index 8fc0f192db..66dcb7ddec 100644 --- a/e2e/compatibility_test.go +++ b/e2e/compatibility_test.go @@ -124,31 +124,37 @@ var knownIncompatibilities = map[string]knownIncompat{ // device interface / link commands: --mtu requirement changed from 2048 to 9000. // Versions before 0.12.0 didn't have these commands; versions 0.12.0–0.15.x send // the old MTU value which the current program rejects. - "write/device_interface_create": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_create_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_create_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_create_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_create_wan": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_create_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_accept_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_update": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_set_health": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_set_health_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_get": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_wait_activated": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_wait_activated_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_drain": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_drain_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_delete_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, + // + // Testnet override: extends the incompatible range to cover v0.10.0–v0.16.x. On + // testnet, v0.10.0–v0.11.0 predate the interface/link commands entirely, and + // testnet v0.16.0 was built before the RFC-18 InterfaceV2 (flex_algo_node_segments) + // change, so it can't deserialize the new account format. On mainnet-beta v0.16.0 + // was released after RFC-18, so only the 0.12.0–0.15.x range applies there. + "write/device_interface_create": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_create_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_create_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_create_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_create_wan": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_create_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_accept_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_update": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_set_health": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_set_health_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_get": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_wait_activated": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_wait_activated_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_drain": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_drain_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_delete_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, } // ============================================================================= @@ -781,6 +787,13 @@ func createAndStartVersionDevnet( _, _ = dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero global-config set --multicast-publisher-block 148.51.120.0/21"}) + // Ensure the unicast-default topology exists. The link activate processor (RFC-18) + // requires this topology account to be present. Ignore errors — if the topology + // already exists in the cloned state (e.g. testnet after RFC-18 deployment), this + // is a no-op. Use the current CLI since old CLIs don't have topology commands. + _, _ = dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero link topology create --name unicast-default --constraint include-any"}) + // Start the activator — it needs the PDAs to exist. // Skip the controller (not exercised in compat tests, saves memory). _, err = dn.Activator.StartIfNotRunning(t.Context()) diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index 9547a0b3e7..a898bf96d7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -1479,6 +1479,8 @@ async fn test_interface_create_invalid_mtu_non_cyoa() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1503,6 +1505,7 @@ async fn test_interface_create_invalid_mtu_non_cyoa() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1671,6 +1674,8 @@ async fn test_interface_create_invalid_mtu_cyoa() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1695,6 +1700,7 @@ async fn test_interface_create_invalid_mtu_cyoa() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index eea9e19f30..2e5b6f7cab 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -392,19 +392,19 @@ type FlexAlgoNodeSegment struct { } type Interface struct { - Version uint8 - Status InterfaceStatus - Name string - InterfaceType InterfaceType - InterfaceCYOA InterfaceCYOA - InterfaceDIA InterfaceDIA - LoopbackType LoopbackType - Bandwidth uint64 - Cir uint64 - Mtu uint16 - RoutingMode RoutingMode - VlanId uint16 - IpNet [5]uint8 + Version uint8 + Status InterfaceStatus + Name string + InterfaceType InterfaceType + InterfaceCYOA InterfaceCYOA + InterfaceDIA InterfaceDIA + LoopbackType LoopbackType + Bandwidth uint64 + Cir uint64 + Mtu uint16 + RoutingMode RoutingMode + VlanId uint16 + IpNet [5]uint8 NodeSegmentIdx uint16 UserTunnelEndpoint bool FlexAlgoNodeSegments []FlexAlgoNodeSegment `json:",omitempty"` diff --git a/smartcontract/sdk/rs/src/client.rs b/smartcontract/sdk/rs/src/client.rs index d3aff79af7..e9cb75c1ae 100644 --- a/smartcontract/sdk/rs/src/client.rs +++ b/smartcontract/sdk/rs/src/client.rs @@ -146,7 +146,7 @@ impl DZClient { let payer_pubkey = payer.pubkey(); let mut all_accounts = accounts; - if !all_accounts.iter().any(|a| a.pubkey == payer_pubkey) { + if !all_accounts.iter().any(|a| a.pubkey == payer_pubkey && a.is_signer) { all_accounts.push(AccountMeta::new(payer_pubkey, true)); } all_accounts.push(AccountMeta::new(program::id(), false)); From 2367d4ab18c2204a4d2b162ff36d653d14c6a5c6 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 12:49:47 -0500 Subject: [PATCH 43/54] smartcontract: fix rustfmt formatting in sdk/rs client.rs --- smartcontract/sdk/rs/src/client.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/smartcontract/sdk/rs/src/client.rs b/smartcontract/sdk/rs/src/client.rs index e9cb75c1ae..0581b78c1a 100644 --- a/smartcontract/sdk/rs/src/client.rs +++ b/smartcontract/sdk/rs/src/client.rs @@ -146,7 +146,10 @@ impl DZClient { let payer_pubkey = payer.pubkey(); let mut all_accounts = accounts; - if !all_accounts.iter().any(|a| a.pubkey == payer_pubkey && a.is_signer) { + if !all_accounts + .iter() + .any(|a| a.pubkey == payer_pubkey && a.is_signer) + { all_accounts.push(AccountMeta::new(payer_pubkey, true)); } all_accounts.push(AccountMeta::new(program::id(), false)); From fe08d2a3204c392890da8911c5863a3451b89615 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 13:27:50 -0500 Subject: [PATCH 44/54] smartcontract: fix post-rebase compile errors in topology/create.rs and deserialize.go - topology/create.rs: pass &Pubkey directly to validate_program_account! pda param instead of Some(&Pubkey) - deserialize.go: remove duplicate DeserializeTenant function introduced by rebase conflict resolution --- .../src/processors/topology/create.rs | 2 +- .../sdk/go/serviceability/deserialize.go | 18 ------------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs index 82035c216a..0b9aaaefce 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -91,7 +91,7 @@ pub fn process_topology_create( admin_group_bits_account, program_id, writable = true, - pda = Some(&expected_ab_pda), + pda = &expected_ab_pda, "AdminGroupBits" ); diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index 2f8a6bbd03..8d7cf9d401 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -260,24 +260,6 @@ func DeserializeMulticastGroup(reader *ByteReader, mg *MulticastGroup) { mg.SubscriberCount = reader.ReadU32() } -func DeserializeTenant(reader *ByteReader, tenant *Tenant) { - tenant.AccountType = AccountType(reader.ReadU8()) - tenant.Owner = reader.ReadPubkey() - tenant.BumpSeed = reader.ReadU8() - tenant.Code = reader.ReadString() - tenant.VrfId = reader.ReadU16() - tenant.ReferenceCount = reader.ReadU32() - tenant.Administrators = reader.ReadPubkeySlice() - tenant.PaymentStatus = TenantPaymentStatus(reader.ReadU8()) - tenant.TokenAccount = reader.ReadPubkey() - tenant.MetroRouting = (reader.ReadU8() != 0) - tenant.RouteLiveness = (reader.ReadU8() != 0) - tenant.BillingDiscriminant = reader.ReadU8() - tenant.BillingRate = reader.ReadU64() - tenant.BillingLastDeductionDzEpoch = reader.ReadU64() - // Note: tenant.PubKey is set separately in client.go after deserialization -} - func DeserializeProgramConfig(reader *ByteReader, pc *ProgramConfig) { pc.AccountType = AccountType(reader.ReadU8()) pc.BumpSeed = reader.ReadU8() From f8ffb7dfb3dbf7a823924be822f1c1757dbd8039 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 13:45:25 -0500 Subject: [PATCH 45/54] smartcontract: fix missing include_topologies field in cli user get test --- smartcontract/cli/src/user/get.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index a8680c4f95..481a17a94f 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -373,6 +373,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let device_pubkey = Pubkey::new_unique(); From 58e846f0e162c9ab522525a78c5b3b86961eb9e7 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 14:38:52 -0500 Subject: [PATCH 46/54] smartcontract: remove duplicate test_link_create_invalid_mtu from link_wan_test.rs --- .../tests/link_wan_test.rs | 54 ------------------- 1 file changed, 54 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index bd04cabb4f..3b1e5b9baf 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2957,60 +2957,6 @@ async fn test_link_topology_valid_accepted() { .expect("Setting valid topology on link should succeed"); } -#[tokio::test] -async fn test_link_create_invalid_mtu() { - let ( - mut banks_client, - program_id, - payer, - globalstate_pubkey, - contributor_pubkey, - device_a_pubkey, - device_z_pubkey, - _tunnel_pubkey, - ) = setup_link_env().await; - - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - - // Create link with MTU 1500 (should fail, must be 9000) - let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; - let (tunnel_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); - - let res = try_execute_transaction( - &mut banks_client, - recent_blockhash, - program_id, - DoubleZeroInstruction::CreateLink(LinkCreateArgs { - code: "invalid-mtu".to_string(), - link_type: LinkLinkType::WAN, - bandwidth: 20000000000, - mtu: 1500, - delay_ns: 1000000, - jitter_ns: 100000, - side_a_iface_name: "Ethernet0".to_string(), - side_z_iface_name: Some("Ethernet1".to_string()), - desired_status: Some(LinkDesiredStatus::Activated), - use_onchain_allocation: false, - }), - vec![ - AccountMeta::new(tunnel_pubkey, false), - AccountMeta::new(contributor_pubkey, false), - AccountMeta::new(device_a_pubkey, false), - AccountMeta::new(device_z_pubkey, false), - AccountMeta::new(globalstate_pubkey, false), - ], - &payer, - ) - .await; - - let error_string = format!("{:?}", res.unwrap_err()); - assert!( - error_string.contains("Custom(46)"), - "Expected InvalidMtu error (Custom(46)), got: {}", - error_string - ); -} - // ─── link_topologies update tests ──────────────────────────────────────────── /// Foundation key can reassign link_topologies to a different topology after From 3c428f2745ceded5e8a4c95dac5d02808906644e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 15:20:34 -0500 Subject: [PATCH 47/54] smartcontract: fix Cargo.lock conflict and regenerate fixtures after rebase --- .../testdata/fixtures/topology_info.bin | Bin 56 -> 56 bytes sdk/serviceability/testdata/fixtures/user.json | 2 +- .../fixtures/generate-fixtures/Cargo.lock | 8 -------- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/sdk/serviceability/testdata/fixtures/topology_info.bin b/sdk/serviceability/testdata/fixtures/topology_info.bin index 9f4db1e1b0de9a082d139550ebc3cab3c5a787a9..b787eb7fb1d1eda72163fc2e13aa243d33ce1a8e 100644 GIT binary patch delta 7 OcmcDpU=*ClXbu1c6ah>C delta 7 OcmcDpU=*0hXbu1c5CKa7 diff --git a/sdk/serviceability/testdata/fixtures/user.json b/sdk/serviceability/testdata/fixtures/user.json index 426aa11c38..2093b5a039 100644 --- a/sdk/serviceability/testdata/fixtures/user.json +++ b/sdk/serviceability/testdata/fixtures/user.json @@ -118,4 +118,4 @@ "typ": "u64" } ] -} +} \ No newline at end of file diff --git a/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock b/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock index e4fc896a02..2255b8f0b8 100644 --- a/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock +++ b/sdk/telemetry/testdata/fixtures/generate-fixtures/Cargo.lock @@ -583,8 +583,6 @@ dependencies = [ [[package]] name = "doublezero-config" version = "0.16.0" -version = "0.15.0" ->>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "eyre", "serde", @@ -594,8 +592,6 @@ dependencies = [ [[package]] name = "doublezero-program-common" version = "0.16.0" -version = "0.15.0" ->>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "borsh 1.6.0", "byteorder", @@ -608,8 +604,6 @@ dependencies = [ [[package]] name = "doublezero-serviceability" version = "0.16.0" -version = "0.15.0" ->>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "bitflags", "borsh 1.6.0", @@ -625,8 +619,6 @@ dependencies = [ [[package]] name = "doublezero-telemetry" version = "0.16.0" -version = "0.15.0" ->>>>>>> b1a32ee05 (smartcontract: add link_flags, topology list/filter CLI, SDK updates for RFC-18) dependencies = [ "borsh 1.6.0", "borsh-incremental", From baa3a4421b2874a9724b2a2d9ca127ae68e1c718 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 16:26:00 -0500 Subject: [PATCH 48/54] smartcontract: restore link MTU validation lost in rebase; fix CYOA interface MTU in test --- .../doublezero-serviceability/src/processors/link/create.rs | 6 +++++- .../doublezero-serviceability/tests/link_wan_test.rs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index 22b36e0beb..12ea02aca9 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -9,7 +9,7 @@ use crate::{ contributor::Contributor, device::Device, globalstate::GlobalState, - interface::{InterfaceCYOA, InterfaceDIA, InterfaceStatus}, + interface::{InterfaceCYOA, InterfaceDIA, InterfaceStatus, LINK_MTU}, link::*, }, }; @@ -90,6 +90,10 @@ pub fn process_create_link( // Check if the payer is a signer assert!(payer_account.is_signer, "Payer must be a signer"); + if value.mtu != LINK_MTU { + return Err(DoubleZeroError::InvalidMtu.into()); + } + // Validate and normalize code let mut code = validate_account_code(&value.code).map_err(|_| DoubleZeroError::InvalidAccountCode)?; diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 3b1e5b9baf..428d010a52 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -1058,7 +1058,7 @@ async fn test_wan_link_rejects_cyoa_interface() { bandwidth: 0, ip_net: Some("100.1.0.0/31".parse().unwrap()), cir: 0, - mtu: 9000, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, From d6881e1b34d1b4f9ebde9ae00af318fc23033d55 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 16:43:49 -0500 Subject: [PATCH 49/54] e2e/smartcontract: fix e2e failures caused by missing unicast-default topology and wrong loopback MTU - devnet/smartcontract_init.go: create unicast-default topology during devnet init so link activation succeeds (RFC-18 requires this PDA to exist) - topology_test.rs: change Vpnv4 loopback interface MTU from 1500 to 9000 to satisfy the non-CYOA/DIA interface MTU validation --- e2e/internal/devnet/smartcontract_init.go | 3 +++ .../doublezero-serviceability/tests/topology_test.rs | 10 +++++----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/e2e/internal/devnet/smartcontract_init.go b/e2e/internal/devnet/smartcontract_init.go index 90145ea345..fbb54da6aa 100644 --- a/e2e/internal/devnet/smartcontract_init.go +++ b/e2e/internal/devnet/smartcontract_init.go @@ -78,6 +78,9 @@ func (dn *Devnet) InitSmartContract(ctx context.Context) error { doublezero init + # Create the unicast-default topology required by the link activate processor (RFC-18). + doublezero link topology create --name unicast-default --constraint include-any + doublezero global-config authority set --activator-authority me --sentinel-authority me doublezero global-config set --local-asn 65000 --remote-asn 65342 --device-tunnel-block ` + dn.Spec.DeviceTunnelNet + ` --user-tunnel-block 169.254.0.0/16 --multicastgroup-block 233.84.178.0/24 --multicast-publisher-block 148.51.120.0/21 diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 9173cf8574..7b1fd74c51 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -538,7 +538,7 @@ async fn test_topology_create_backfills_vpnv4_loopbacks() { bandwidth: 0, cir: 0, ip_net: None, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -856,7 +856,7 @@ async fn setup_wan_link( bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -929,7 +929,7 @@ async fn setup_wan_link( bandwidth: 0, ip_net: None, cir: 0, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -1681,7 +1681,7 @@ async fn test_topology_backfill_populates_vpnv4_loopbacks() { bandwidth: 0, cir: 0, ip_net: None, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, @@ -2035,7 +2035,7 @@ async fn test_topology_backfill_avoids_collision_with_existing_node_segment_idx( bandwidth: 0, cir: 0, ip_net: None, - mtu: 1500, + mtu: 9000, routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, From a92da563b1459f52ca23f64ecb9252e072e3afab Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 17:34:43 -0500 Subject: [PATCH 50/54] e2e: move unicast-default topology creation after global-config set --- e2e/internal/devnet/smartcontract_init.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e/internal/devnet/smartcontract_init.go b/e2e/internal/devnet/smartcontract_init.go index fbb54da6aa..6274e8c750 100644 --- a/e2e/internal/devnet/smartcontract_init.go +++ b/e2e/internal/devnet/smartcontract_init.go @@ -78,15 +78,16 @@ func (dn *Devnet) InitSmartContract(ctx context.Context) error { doublezero init - # Create the unicast-default topology required by the link activate processor (RFC-18). - doublezero link topology create --name unicast-default --constraint include-any - doublezero global-config authority set --activator-authority me --sentinel-authority me doublezero global-config set --local-asn 65000 --remote-asn 65342 --device-tunnel-block ` + dn.Spec.DeviceTunnelNet + ` --user-tunnel-block 169.254.0.0/16 --multicastgroup-block 233.84.178.0/24 --multicast-publisher-block 148.51.120.0/21 doublezero global-config authority set --activator-authority me --sentinel-authority me + # Create the unicast-default topology required by the link activate processor (RFC-18). + # Must run after global-config set, which initializes the admin_group_bits resource extension. + doublezero link topology create --name unicast-default --constraint include-any + doublezero location create --code lax --name "Los Angeles" --country US --lat 34.049641274076464 --lng -118.25939642499903 doublezero location create --code ewr --name "New York" --country US --lat 40.780297071772125 --lng -74.07203003496925 doublezero location create --code lhr --name "London" --country UK --lat 51.513999803939384 --lng -0.12014764843092213 From 1cc8cff982d7deaec38be02650c9eaab8b28c270 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Tue, 7 Apr 2026 18:17:33 -0500 Subject: [PATCH 51/54] smartcontract/e2e: fix topology UpdateLink accounts and compat ranges - topology_test.rs: fix assign_link_topology helper to pass topology pubkeys as extra accounts in the UpdateLink transaction; the processor validates topology accounts as trailing accounts after system_program, but the helper was only including them in the instruction args - e2e/compatibility_test.go: extend default known-incompatible range for interface-update and link steps from [0.12.0, 0.16.0) to [0.10.0, 0.16.0); RFC-18 added flex_algo_node_segments to InterfaceV2 which causes v0.10.0 and v0.11.0 to fail deserialization ("Interface not found") when reading back accounts written by the new program (trailing bytes); interface create steps are unaffected (old CLIs don't read back existing interfaces) --- .gitignore | 1 + e2e/compatibility_test.go | 59 ++++++++++--------- .../tests/topology_test.rs | 7 ++- 3 files changed, 37 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index b8a8b47452..e905c41978 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ dev/.deploy/ # Ignore the directory used for checking out the monitor tool in CI /doublezero_monitor/ .claude/settings.local.json +.worktrees/ diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go index 66dcb7ddec..22e5a5f3fe 100644 --- a/e2e/compatibility_test.go +++ b/e2e/compatibility_test.go @@ -121,40 +121,41 @@ var knownIncompatibilities = map[string]knownIncompat{ "write/device_drain": {ranges: before("0.8.1")}, "write/device_drain_2": {ranges: before("0.8.1")}, - // device interface / link commands: --mtu requirement changed from 2048 to 9000. - // Versions before 0.12.0 didn't have these commands; versions 0.12.0–0.15.x send - // the old MTU value which the current program rejects. + // device interface / link commands: --mtu requirement changed from 2048 to 9000 in v0.16.0, + // and RFC-18 added flex_algo_node_segments to InterfaceV2 which old CLIs cannot deserialize. + // Versions 0.12.0–0.15.x send the old MTU value which the current program rejects. + // Versions 0.10.0–0.11.0 can CREATE interfaces (the CLI sends the instruction without reading + // back), but cannot UPDATE or otherwise operate on them because reading the new InterfaceV2 + // format (with trailing flex_algo_node_segments bytes) fails deserialization. // - // Testnet override: extends the incompatible range to cover v0.10.0–v0.16.x. On - // testnet, v0.10.0–v0.11.0 predate the interface/link commands entirely, and - // testnet v0.16.0 was built before the RFC-18 InterfaceV2 (flex_algo_node_segments) - // change, so it can't deserialize the new account format. On mainnet-beta v0.16.0 - // was released after RFC-18, so only the 0.12.0–0.15.x range applies there. + // Testnet override: extends the incompatible range to cover v0.10.0–v0.16.x. Testnet v0.16.0 + // was built before the RFC-18 InterfaceV2 change and can't deserialize the new account format. + // On mainnet-beta v0.16.0 was released after RFC-18, so it is compatible. "write/device_interface_create": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, "write/device_interface_create_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, "write/device_interface_create_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, "write/device_interface_create_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_set_unlinked": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_set_unlinked_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_set_unlinked_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_set_unlinked_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_create_wan": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_create_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_accept_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_update": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_set_health": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_set_health_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_get": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_wait_activated": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_wait_activated_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_drain": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_drain_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/link_delete_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_delete_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_delete_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_delete_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_2": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_3": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_4": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_create_wan": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_create_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_accept_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_update": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_set_health": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_set_health_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_get": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_wait_activated": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_wait_activated_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_drain": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_drain_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_delete": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_delete_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_2": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_3": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_4": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, } // ============================================================================= diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index 7b1fd74c51..316312e77a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -1063,7 +1063,11 @@ async fn assign_link_topology( payer: &Keypair, ) { let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - execute_transaction( + let extra_accounts: Vec = topology_pubkeys + .iter() + .map(|pk| AccountMeta::new_readonly(*pk, false)) + .collect(); + execute_transaction_with_extra_accounts( banks_client, recent_blockhash, program_id, @@ -1077,6 +1081,7 @@ async fn assign_link_topology( AccountMeta::new_readonly(globalstate_pubkey, false), ], payer, + &extra_accounts, ) .await; } From 0551df22512e030aa02eab8689258c3e134347b8 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 8 Apr 2026 00:28:19 -0500 Subject: [PATCH 52/54] smartcontract: fix telemetry and serviceability test setup for RFC-18 - Add admin_group_bits_pda to SetGlobalConfig call in telemetry ServiceabilityProgramHelper::new(), which RFC-18 requires as the 10th account - Create the unicast-default topology in ServiceabilityProgramHelper::new() and pass it to ActivateLink, which RFC-18 requires for auto-tagging - Add unicast_default_topology_pubkey field to ServiceabilityProgramHelper - Restore WAN link mtu values to 9000 in initialize_device_latency_samples_tests.rs (rebase artifact: incorrectly changed to 0/1500/4500) - Set compute_max_units(1_000_000) on all inline ProgramTest setups that call SetGlobalConfig; RFC-18 creates an additional AdminGroupBits resource extension account which can exceed the default 200k CU limit under parallel test load --- .../tests/create_subscribe_user_test.rs | 18 +++++---- .../tests/device_test.rs | 8 ++-- .../tests/device_update_location_test.rs | 8 ++-- .../tests/exchange_test.rs | 8 ++-- .../tests/test_helpers.rs | 10 +++-- .../tests/user_onchain_allocation_test.rs | 16 ++++---- ...initialize_device_latency_samples_tests.rs | 14 +++---- .../tests/test_helpers.rs | 40 +++++++++++++++++-- 8 files changed, 80 insertions(+), 42 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs index a57b0bbe75..723bc655f1 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs @@ -86,13 +86,13 @@ struct CreateSubscribeFixture { async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFixture { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -1528,13 +1528,13 @@ async fn test_create_subscribe_user_foundation_owner_override() { let client_ip = [100, 0, 0, 30]; let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -1864,6 +1864,7 @@ async fn test_create_subscribe_user_sentinel_owner_override() { program_id, processor!(process_instruction), ); + program_test.set_compute_max_units(1_000_000); // Fund the sentinel program_test.add_account( @@ -2223,6 +2224,7 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { program_id, processor!(process_instruction), ); + program_test.set_compute_max_units(1_000_000); // Fund the non-foundation payer so it can sign transactions program_test.add_account( diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs index 0a2b9812e8..082b9f1bff 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs @@ -1022,13 +1022,13 @@ async fn test_device_update_multicast_counts_ignored_for_non_foundation_payer() async fn setup_program_with_location_and_exchange( ) -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey, Pubkey, Pubkey) { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; // Start with a fresh program let (program_config_pubkey, _) = get_program_config_pda(&program_id); diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs index 3ce9fd61ed..e9f8cb329d 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs @@ -21,13 +21,13 @@ use test_helpers::*; #[tokio::test] async fn device_update_location_test() { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; /***********************************************************************************************************************************/ println!("🟢 Start test_device"); diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs index 66ee1f458a..6a14393eb4 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs @@ -534,13 +534,13 @@ async fn test_exchange_owner_and_foundation_can_update_status() { #[tokio::test] async fn test_exchange_bgp_community_autoassignment() { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; println!("🟢 Start test_exchange_bgp_community_autoassignment"); diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index f784d12bc7..e574ea21f3 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -479,13 +479,15 @@ pub async fn wait_for_new_blockhash(banks_client: &mut BanksClient) -> solana_pr pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey) { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + // SetGlobalConfig creates multiple ResourceExtension accounts; raise the budget so this + // doesn't flake under load when many test processes run concurrently. + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs index f611677505..27fe52160a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs @@ -85,13 +85,13 @@ async fn setup_user_onchain_allocation_test( // (user_tunnel_block is immutable once set, so we can't override it later) let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -1949,13 +1949,13 @@ async fn setup_user_infra_without_user( ) { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index 0bb16d2383..2a6c6cfb2a 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -703,7 +703,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { jitter_ns: 10000, delay_override_ns: 0, link_type: LinkLinkType::WAN, - mtu: 0, + mtu: 9000, tunnel_id: 0, tunnel_net: NetworkV4::default(), side_a_iface_name: "Ethernet0".to_string(), @@ -854,7 +854,7 @@ async fn test_initialize_device_latency_samples_fail_origin_device_not_activated code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 1500, + mtu: 9000, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -992,7 +992,7 @@ async fn test_initialize_device_latency_samples_fail_target_device_not_activated code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 1500, + mtu: 9000, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1128,7 +1128,7 @@ async fn test_initialize_device_latency_samples_success_provisioning_link() { code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 1500, + mtu: 9000, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1392,7 +1392,7 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_devices() { code: "LINK1".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 1500, + mtu: 9000, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), @@ -1528,7 +1528,7 @@ async fn test_initialize_device_latency_samples_succeeds_with_reversed_link_side code: "LINK1".into(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 1500, + mtu: 9000, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet1".to_string(), @@ -1861,7 +1861,7 @@ async fn test_initialize_device_latency_samples_fail_agent_not_owner_of_origin_d code: "LNK".to_string(), link_type: LinkLinkType::WAN, bandwidth: 10_000_000_000, - mtu: 4500, + mtu: 9000, delay_ns: 1000000, jitter_ns: 100000, side_a_iface_name: "Ethernet0".to_string(), diff --git a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs index 96fdb07873..16674379f3 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs @@ -5,7 +5,7 @@ use doublezero_serviceability::{ pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_globalstate_pda, get_link_pda, get_location_pda, get_program_config_pda, - get_resource_extension_pda, + get_resource_extension_pda, get_topology_pda, }, processors::{ contributor::create::ContributorCreateArgs, @@ -23,6 +23,7 @@ use doublezero_serviceability::{ update::LinkUpdateArgs, }, location::{create::LocationCreateArgs, suspend::LocationSuspendArgs}, + topology::create::TopologyCreateArgs, }, resource::ResourceType, state::{ @@ -32,6 +33,7 @@ use doublezero_serviceability::{ interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, link::{Link, LinkDesiredStatus, LinkHealth, LinkLinkType}, location::Location, + topology::TopologyConstraint, }, }; use doublezero_telemetry::{ @@ -740,6 +742,7 @@ pub struct ServiceabilityProgramHelper { pub global_state_pubkey: Pubkey, pub global_config_pubkey: Pubkey, + pub unicast_default_topology_pubkey: Pubkey, } impl ServiceabilityProgramHelper { @@ -747,7 +750,7 @@ impl ServiceabilityProgramHelper { context: Arc>, program_id: Pubkey, ) -> Result { - let (global_state_pubkey, global_config_pubkey) = { + let (global_state_pubkey, global_config_pubkey, unicast_default_topology_pubkey) = { let (mut banks_client, payer, recent_blockhash) = { let context = context.lock().unwrap(); ( @@ -787,6 +790,8 @@ impl ServiceabilityProgramHelper { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_serviceability_instruction( &mut banks_client, &payer, @@ -811,11 +816,38 @@ impl ServiceabilityProgramHelper { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], ) .await?; - (global_state_pubkey, global_config_pubkey) + // Create the unicast-default topology (required for ActivateLink). + // Must run after SetGlobalConfig, which initializes the AdminGroupBits resource. + let (unicast_default_topology_pubkey, _) = + get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await?; + execute_serviceability_instruction( + &mut banks_client, + &payer, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(unicast_default_topology_pubkey, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(global_state_pubkey, false), + ], + ) + .await?; + + ( + global_state_pubkey, + global_config_pubkey, + unicast_default_topology_pubkey, + ) }; Ok(Self { @@ -824,6 +856,7 @@ impl ServiceabilityProgramHelper { global_state_pubkey, global_config_pubkey, + unicast_default_topology_pubkey, }) } @@ -1192,6 +1225,7 @@ impl ServiceabilityProgramHelper { AccountMeta::new(side_a_pk, false), AccountMeta::new(side_z_pk, false), AccountMeta::new(self.global_state_pubkey, false), + AccountMeta::new_readonly(self.unicast_default_topology_pubkey, false), ], ) .await?; From 7fb35232045104e80ceff7cc96f67457e3ee064e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 8 Apr 2026 01:12:05 -0500 Subject: [PATCH 53/54] e2e,sdk: fix device_interface_create known-incompat range; increase TS compat timeout The testnet envOverride for device_interface_create* was incorrectly including v0.10.0, v0.11.0, and v0.16.0. These CLI versions can successfully create interfaces (the create instruction doesn't read back the account), so the step was passing but our known-incompatible table said it should fail. Testnet v0.16.0 can't deserialize the new InterfaceV2 format (built before RFC-18), but that only affects operations that READ interface accounts. The create step itself succeeds. The default range [0.12.0, 0.16.0) covers the MTU issue (versions that send the wrong MTU value). Also increase the TypeScript compat test setDefaultTimeout from 30s to 120s. The getProgramData test fetches all mainnet accounts via RPC, which can take 60-90s during busy periods, causing the test to timeout. --- e2e/compatibility_test.go | 15 ++++++++------- .../serviceability/tests/compat.test.ts | 3 ++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go index 22e5a5f3fe..1552d3c978 100644 --- a/e2e/compatibility_test.go +++ b/e2e/compatibility_test.go @@ -128,13 +128,14 @@ var knownIncompatibilities = map[string]knownIncompat{ // back), but cannot UPDATE or otherwise operate on them because reading the new InterfaceV2 // format (with trailing flex_algo_node_segments bytes) fails deserialization. // - // Testnet override: extends the incompatible range to cover v0.10.0–v0.16.x. Testnet v0.16.0 - // was built before the RFC-18 InterfaceV2 change and can't deserialize the new account format. - // On mainnet-beta v0.16.0 was released after RFC-18, so it is compatible. - "write/device_interface_create": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_create_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_create_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, - "write/device_interface_create_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + // Testnet v0.16.0 was built before the RFC-18 InterfaceV2 change and cannot deserialize the + // new account format. However it CAN create interfaces (the create instruction doesn't read + // back), so device_interface_create does not need a testnet override. Operations that read + // interface accounts (set_unlinked, link_create, etc.) do need the override. + "write/device_interface_create": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, + "write/device_interface_create_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, + "write/device_interface_create_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, + "write/device_interface_create_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, "write/device_interface_set_unlinked": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, "write/device_interface_set_unlinked_2": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, "write/device_interface_set_unlinked_3": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, diff --git a/sdk/serviceability/typescript/serviceability/tests/compat.test.ts b/sdk/serviceability/typescript/serviceability/tests/compat.test.ts index b041bb5c59..e286d0d16a 100644 --- a/sdk/serviceability/typescript/serviceability/tests/compat.test.ts +++ b/sdk/serviceability/typescript/serviceability/tests/compat.test.ts @@ -13,7 +13,8 @@ import { describe, expect, test, setDefaultTimeout } from "bun:test"; // Compat tests hit public RPC endpoints which may be slow or rate-limited. -setDefaultTimeout(30_000); +// The getProgramData test fetches all mainnet accounts and can take 60-90s during busy periods. +setDefaultTimeout(120_000); import { Connection, PublicKey } from "@solana/web3.js"; import { PROGRAM_IDS, LEDGER_RPC_URLS } from "../config.js"; import { newConnection } from "../rpc.js"; From a3a28338112fd646c81c6b992020472d0e6679e3 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 8 Apr 2026 08:53:34 -0500 Subject: [PATCH 54/54] sdk/ts: add per-request timeout to RPC connection to prevent indefinite hangs --- .../typescript/serviceability/rpc.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/sdk/serviceability/typescript/serviceability/rpc.ts b/sdk/serviceability/typescript/serviceability/rpc.ts index ab2f0ff835..50af3cb379 100644 --- a/sdk/serviceability/typescript/serviceability/rpc.ts +++ b/sdk/serviceability/typescript/serviceability/rpc.ts @@ -1,6 +1,7 @@ import { Connection, type ConnectionConfig } from "@solana/web3.js"; const DEFAULT_MAX_RETRIES = 5; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; /** * Creates a Solana RPC Connection with retry on 429 Too Many Requests. @@ -8,18 +9,29 @@ const DEFAULT_MAX_RETRIES = 5; * The built-in @solana/web3.js retry uses short backoffs (500ms-4s) that * may not be sufficient for rate-limited public RPC endpoints. This wrapper * provides longer backoff intervals (2s, 4s, 6s, 8s, 10s). + * + * Also adds a per-request timeout so that hung connections (no response from + * the RPC server) fail fast rather than waiting indefinitely. */ export function newConnection( url: string, - config?: ConnectionConfig & { maxRetries?: number }, + config?: ConnectionConfig & { maxRetries?: number; requestTimeoutMs?: number }, ): Connection { const maxRetries = config?.maxRetries ?? DEFAULT_MAX_RETRIES; + const requestTimeoutMs = config?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const retryFetch = async ( input: Parameters[0], init?: Parameters[1], ): Promise => { for (let attempt = 0; ; attempt++) { - const response = await fetch(input, init); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), requestTimeoutMs); + let response: Response; + try { + response = await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } if (response.status !== 429 || attempt >= maxRetries) { return response; }