From a713da1322ebc133467760f16ba83105eacea7e4 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Fri, 1 May 2026 17:01:55 -0500 Subject: [PATCH 1/5] controller: stamp default topology color for tenantless users Community stamping only resolved colors for users with a tenant assigned. Since most users (all of mainnet, most of testnet) have no tenant, they were silently skipped. Add an else branch that calls resolveTenantColors with nil includeTopologies, which falls through to the default UNICAST-DEFAULT color. --- controlplane/controller/internal/controller/server.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/controlplane/controller/internal/controller/server.go b/controlplane/controller/internal/controller/server.go index 91ac4c4e5..2e4786f35 100644 --- a/controlplane/controller/internal/controller/server.go +++ b/controlplane/controller/internal/controller/server.go @@ -576,6 +576,8 @@ func (c *Controller) updateStateCache(ctx context.Context) error { if c.featuresConfig != nil && c.featuresConfig.Features.FlexAlgo.Enabled { tunnel.TenantTopologyColors = resolveTenantColors(tenant.IncludeTopologies, cache.Topologies) } + } else if c.featuresConfig != nil && c.featuresConfig.Features.FlexAlgo.Enabled { + tunnel.TenantTopologyColors = resolveTenantColors(nil, cache.Topologies) } } From ef5d78e090a4800d485da248fd468bf89bcbd300 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Fri, 1 May 2026 18:00:23 -0500 Subject: [PATCH 2/5] smartcontract: assign flex-algo node segments during interface creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When creating a VPNv4 loopback with onchain allocation, the instruction now accepts optional topology PDA accounts and allocates a FlexAlgoNodeSegment for each. This replaces the activator's post- activation backfill that was lost during the activator removal. The CLI automatically discovers existing topologies and passes them to the instruction, so `doublezero device interface create ... --loopback-type vpn-ipv4` assigns all node segments atomically — no manual steps. Also rename BackfillTopology → AssignTopologyNodeSegments across the codebase (instruction discriminant 110 unchanged). --- client/doublezero/src/cli/link.rs | 6 +-- client/doublezero/src/main.rs | 2 +- .../doublezero-admin/src/cli/migrate.rs | 13 ++++--- .../cli/src/device/interface/create.rs | 22 +++++++++-- smartcontract/cli/src/doublezerocommand.rs | 12 ++++-- .../{backfill.rs => assign_node_segments.rs} | 36 +++++++++--------- smartcontract/cli/src/topology/mod.rs | 2 +- .../src/entrypoint.rs | 9 +++-- .../src/instructions.rs | 23 ++++++------ .../src/processors/device/interface/create.rs | 36 ++++++++++++++++-- .../{backfill.rs => assign_node_segments.rs} | 37 ++++++++++++------- .../src/processors/topology/mod.rs | 2 +- .../tests/global_test.rs | 2 + .../interface_onchain_allocation_test.rs | 11 ++++++ .../tests/interface_test.rs | 12 ++++++ .../tests/link_dzx_test.rs | 2 + .../tests/link_onchain_allocation_test.rs | 12 ++++++ .../tests/link_wan_test.rs | 10 +++++ .../tests/resource_extension_test.rs | 1 + .../tests/topology_test.rs | 18 +++++---- .../tests/unlink_device_interface_test.rs | 3 ++ .../src/commands/device/interface/create.rs | 21 ++++++++++- .../{backfill.rs => assign_node_segments.rs} | 31 +++++++++------- .../sdk/rs/src/commands/topology/create.rs | 12 +++--- .../sdk/rs/src/commands/topology/mod.rs | 2 +- 25 files changed, 241 insertions(+), 96 deletions(-) rename smartcontract/cli/src/topology/{backfill.rs => assign_node_segments.rs} (65%) rename smartcontract/programs/doublezero-serviceability/src/processors/topology/{backfill.rs => assign_node_segments.rs} (81%) rename smartcontract/sdk/rs/src/commands/topology/{backfill.rs => assign_node_segments.rs} (85%) diff --git a/client/doublezero/src/cli/link.rs b/client/doublezero/src/cli/link.rs index 826547cbb..58f86086a 100644 --- a/client/doublezero/src/cli/link.rs +++ b/client/doublezero/src/cli/link.rs @@ -6,7 +6,7 @@ use doublezero_cli::{ wan_create::*, }, topology::{ - backfill::BackfillTopologyCliCommand, clear::ClearTopologyCliCommand, + assign_node_segments::AssignTopologyNodeSegmentsCliCommand, clear::ClearTopologyCliCommand, create::CreateTopologyCliCommand, delete::DeleteTopologyCliCommand, list::ListTopologyCliCommand, }, @@ -79,8 +79,8 @@ pub enum TopologyCommands { Delete(DeleteTopologyCliCommand), /// Clear a topology from links Clear(ClearTopologyCliCommand), - /// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks - Backfill(BackfillTopologyCliCommand), + /// Assign FlexAlgoNodeSegment entries on Vpnv4 loopbacks + AssignNodeSegments(AssignTopologyNodeSegmentsCliCommand), /// List all topologies List(ListTopologyCliCommand), } diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index a3d67957c..06a3b2251 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -271,7 +271,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::AssignNodeSegments(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 29a0d058b..b8f5e993b 100644 --- a/controlplane/doublezero-admin/src/cli/migrate.rs +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -3,7 +3,9 @@ use doublezero_cli::doublezerocommand::CliCommand; use doublezero_sdk::commands::{ device::list::ListDeviceCommand, link::{list::ListLinkCommand, update::UpdateLinkCommand}, - topology::{backfill::BackfillTopologyCommand, list::ListTopologyCommand}, + topology::{ + assign_node_segments::AssignTopologyNodeSegmentsCommand, list::ListTopologyCommand, + }, }; use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; use solana_sdk::pubkey::Pubkey; @@ -133,10 +135,11 @@ impl FlexAlgoMigrateCliCommand { )?; if !self.dry_run { - let result = client.backfill_topology(BackfillTopologyCommand { - name: topology.name.clone(), - device_pubkeys: devices_needing_backfill, - }); + let result = + client.assign_topology_node_segments(AssignTopologyNodeSegmentsCommand { + name: topology.name.clone(), + device_pubkeys: devices_needing_backfill, + }); match result { Ok(sigs) => { writeln!(out, " backfilled in {} transaction(s)", sigs.len())?; diff --git a/smartcontract/cli/src/device/interface/create.rs b/smartcontract/cli/src/device/interface/create.rs index b1e5f421a..594d315a4 100644 --- a/smartcontract/cli/src/device/interface/create.rs +++ b/smartcontract/cli/src/device/interface/create.rs @@ -7,8 +7,12 @@ use crate::{ }; use clap::Args; use doublezero_program_common::{types::network_v4::NetworkV4, validate_iface}; -use doublezero_sdk::commands::device::{ - get::GetDeviceCommand, interface::create::CreateDeviceInterfaceCommand, list::ListDeviceCommand, +use doublezero_sdk::commands::{ + device::{ + get::GetDeviceCommand, interface::create::CreateDeviceInterfaceCommand, + list::ListDeviceCommand, + }, + topology::list::ListTopologyCommand, }; use std::io::Write; @@ -114,11 +118,21 @@ impl CreateDeviceInterfaceCliCommand { } } + // For Vpnv4 loopbacks, discover existing topologies so the onchain program + // can assign FlexAlgoNodeSegment entries atomically during creation. + let loopback_type = self.loopback_type.map(|lt| lt.into()).unwrap_or_default(); + let topology_names = if loopback_type == doublezero_sdk::LoopbackType::Vpnv4 { + let topologies = client.list_topology(ListTopologyCommand)?; + topologies.values().map(|t| t.name.clone()).collect() + } else { + vec![] + }; + let (signature, _) = client.create_device_interface(CreateDeviceInterfaceCommand { pubkey: device_pk, name: self.name.clone(), ip_net: self.ip_net, - loopback_type: self.loopback_type.map(|lt| lt.into()).unwrap_or_default(), + loopback_type, interface_cyoa: self.interface_cyoa.map(|ic| ic.into()).unwrap_or_default(), interface_dia: self.interface_dia.map(|id| id.into()).unwrap_or_default(), bandwidth: self.bandwidth, @@ -127,6 +141,7 @@ impl CreateDeviceInterfaceCliCommand { routing_mode: self.routing_mode.into(), vlan_id: self.vlan_id, user_tunnel_endpoint: self.user_tunnel_endpoint.unwrap_or(false), + topology_names, })?; writeln!(out, "Signature: {signature}")?; @@ -365,6 +380,7 @@ mod tests { routing_mode: RoutingMode::Static, vlan_id: 20, user_tunnel_endpoint: false, + topology_names: vec![], })) .times(1) .returning(move |_| Ok((signature, device1_pubkey))); diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index d13a343b6..47e7b6fdd 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -102,7 +102,7 @@ use doublezero_sdk::{ update_payment_status::UpdatePaymentStatusCommand, }, topology::{ - backfill::BackfillTopologyCommand, + assign_node_segments::AssignTopologyNodeSegmentsCommand, clear::ClearTopologyCommand, create::{CreateTopologyCommand, CreateTopologyResult}, delete::DeleteTopologyCommand, @@ -355,7 +355,10 @@ pub trait CliCommand { fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result; 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 assign_topology_node_segments( + &self, + cmd: AssignTopologyNodeSegmentsCommand, + ) -> eyre::Result>; fn list_topology( &self, cmd: ListTopologyCommand, @@ -841,7 +844,10 @@ impl CliCommand for CliCommandImpl<'_> { fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result> { cmd.execute(self.client) } - fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result> { + fn assign_topology_node_segments( + &self, + cmd: AssignTopologyNodeSegmentsCommand, + ) -> eyre::Result> { cmd.execute(self.client) } fn list_topology( diff --git a/smartcontract/cli/src/topology/backfill.rs b/smartcontract/cli/src/topology/assign_node_segments.rs similarity index 65% rename from smartcontract/cli/src/topology/backfill.rs rename to smartcontract/cli/src/topology/assign_node_segments.rs index a35eb7a04..6c04bbfa0 100644 --- a/smartcontract/cli/src/topology/backfill.rs +++ b/smartcontract/cli/src/topology/assign_node_segments.rs @@ -3,40 +3,38 @@ use crate::{ requirements::{CHECK_BALANCE, CHECK_ID_JSON}, }; use clap::Args; -use doublezero_sdk::commands::topology::backfill::BackfillTopologyCommand; +use doublezero_sdk::commands::topology::assign_node_segments::AssignTopologyNodeSegmentsCommand; use solana_sdk::pubkey::Pubkey; use std::io::Write; #[derive(Args, Debug)] -pub struct BackfillTopologyCliCommand { - /// Name of the topology to backfill +pub struct AssignTopologyNodeSegmentsCliCommand { + /// Name of the topology to assign node segments for #[arg(long)] pub name: String, - /// Device account pubkeys to backfill (one or more) + /// Device account pubkeys (one or more) #[arg(long = "device", value_name = "PUBKEY")] pub device_pubkeys: Vec, } -impl BackfillTopologyCliCommand { +impl AssignTopologyNodeSegmentsCliCommand { 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" - )); + return Err(eyre::eyre!("at least one --device pubkey is required")); } let name = self.name.to_uppercase(); - let sigs = client.backfill_topology(BackfillTopologyCommand { + let sigs = client.assign_topology_node_segments(AssignTopologyNodeSegmentsCommand { name: name.clone(), device_pubkeys: self.device_pubkeys, })?; writeln!( out, - "Backfilled topology '{}' across {} transaction(s).", + "Assigned node segments for topology '{}' across {} transaction(s).", name, sigs.len() )?; @@ -49,25 +47,25 @@ impl BackfillTopologyCliCommand { mod tests { use super::*; use crate::doublezerocommand::MockCliCommand; - use doublezero_sdk::commands::topology::backfill::BackfillTopologyCommand; + use doublezero_sdk::commands::topology::assign_node_segments::AssignTopologyNodeSegmentsCommand; use mockall::predicate::eq; use solana_sdk::{pubkey::Pubkey, signature::Signature}; use std::io::Cursor; #[test] - fn test_backfill_topology_execute_success() { + fn test_assign_topology_node_segments_execute_success() { let mut mock = MockCliCommand::new(); let device1 = Pubkey::new_unique(); mock.expect_check_requirements().returning(|_| Ok(())); - mock.expect_backfill_topology() - .with(eq(BackfillTopologyCommand { + mock.expect_assign_topology_node_segments() + .with(eq(AssignTopologyNodeSegmentsCommand { name: "UNICAST-DEFAULT".to_string(), device_pubkeys: vec![device1], })) .returning(|_| Ok(vec![Signature::new_unique()])); - let cmd = BackfillTopologyCliCommand { + let cmd = AssignTopologyNodeSegmentsCliCommand { name: "unicast-default".to_string(), device_pubkeys: vec![device1], }; @@ -75,15 +73,17 @@ 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("Backfilled topology 'UNICAST-DEFAULT' across 1 transaction(s).")); + assert!(output.contains( + "Assigned node segments for topology 'UNICAST-DEFAULT' across 1 transaction(s)." + )); } #[test] - fn test_backfill_topology_requires_at_least_one_device() { + fn test_assign_topology_node_segments_requires_at_least_one_device() { let mut mock = MockCliCommand::new(); mock.expect_check_requirements().returning(|_| Ok(())); - let cmd = BackfillTopologyCliCommand { + let cmd = AssignTopologyNodeSegmentsCliCommand { name: "unicast-default".to_string(), device_pubkeys: vec![], }; diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs index 64a97eba3..21035546a 100644 --- a/smartcontract/cli/src/topology/mod.rs +++ b/smartcontract/cli/src/topology/mod.rs @@ -1,4 +1,4 @@ -pub mod backfill; +pub mod assign_node_segments; 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 c9ea78cad..7d0a14641 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -98,8 +98,9 @@ use crate::{ update::process_update_tenant, update_payment_status::process_update_payment_status, }, topology::{ - backfill::process_topology_backfill, clear::process_topology_clear, - create::process_topology_create, delete::process_topology_delete, + assign_node_segments::process_assign_topology_node_segments, + clear::process_topology_clear, create::process_topology_create, + delete::process_topology_delete, }, user::{ activate::process_activate_user, ban::process_ban_user, @@ -444,8 +445,8 @@ pub fn process_instruction( DoubleZeroInstruction::ClearTopology(value) => { process_topology_clear(program_id, accounts, &value)? } - DoubleZeroInstruction::BackfillTopology(value) => { - process_topology_backfill(program_id, accounts, &value)? + DoubleZeroInstruction::AssignTopologyNodeSegments(value) => { + process_assign_topology_node_segments(program_id, accounts, &value)? } DoubleZeroInstruction::Deprecated111() => (), }; diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 3a6fe893f..e306eb75c 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -81,8 +81,8 @@ use crate::processors::{ update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, }, topology::{ - backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, - delete::TopologyDeleteArgs, + assign_node_segments::AssignTopologyNodeSegmentsArgs, clear::TopologyClearArgs, + create::TopologyCreateArgs, delete::TopologyDeleteArgs, }, user::{ activate::UserActivateArgs, ban::UserBanArgs, check_access_pass::CheckUserAccessPassArgs, @@ -229,10 +229,10 @@ pub enum DoubleZeroInstruction { DeleteIndex(IndexDeleteArgs), // variant 105 SetUserBGPStatus(SetUserBGPStatusArgs), // variant 106 - CreateTopology(TopologyCreateArgs), // variant 107 - DeleteTopology(TopologyDeleteArgs), // variant 108 - ClearTopology(TopologyClearArgs), // variant 109 - BackfillTopology(TopologyBackfillArgs), // variant 110 + CreateTopology(TopologyCreateArgs), // variant 107 + DeleteTopology(TopologyDeleteArgs), // variant 108 + ClearTopology(TopologyClearArgs), // variant 109 + AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs), // variant 110 Deprecated111(), // variant 111, (was MigrateDeviceInterfaces) } @@ -374,7 +374,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())), + 110 => Ok(Self::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs::try_from(rest).unwrap())), 111 => Ok(Self::Deprecated111()), _ => Err(ProgramError::InvalidInstructionData), @@ -518,7 +518,7 @@ impl DoubleZeroInstruction { 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 + Self::AssignTopologyNodeSegments(_) => "AssignTopologyNodeSegments".to_string(), // variant 110 Self::Deprecated111() => "Deprecated111".to_string(), // variant 111 } } @@ -654,7 +654,7 @@ impl DoubleZeroInstruction { 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::AssignTopologyNodeSegments(args) => format!("{args:?}"), // variant 110 Self::Deprecated111() => String::new(), // variant 111 } } @@ -1204,6 +1204,7 @@ mod tests { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), "CreateDeviceInterface", ); @@ -1376,10 +1377,10 @@ mod tests { "ClearTopology", ); test_instruction( - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), - "BackfillTopology", + "AssignTopologyNodeSegments", ); } } 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 7488559b4..80e6ea09f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.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, @@ -17,6 +17,7 @@ use crate::{ Interface, InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, CURRENT_INTERFACE_SCHEMA_VERSION, CYOA_DIA_INTERFACE_MTU, INTERFACE_MTU, }, + topology::FlexAlgoNodeSegment, }, }; use borsh::BorshSerialize; @@ -48,6 +49,11 @@ pub struct DeviceInterfaceCreateArgs { /// Performs atomic create+allocate+activate in a single transaction. #[incremental(default = false)] pub use_onchain_allocation: bool, + /// Number of topology PDA accounts appended after segment_routing_ids. + /// For each topology, the processor allocates a FlexAlgoNodeSegment on + /// Vpnv4 loopbacks. Zero means no topologies (backward compatible). + #[incremental(default = 0)] + pub topology_count: u8, } impl fmt::Debug for DeviceInterfaceCreateArgs { @@ -56,7 +62,7 @@ impl fmt::Debug for DeviceInterfaceCreateArgs { f, "name: {}, loopback_type: {}, vlan_id: {}, ip_net: {:?}, user_tunnel_endpoint: {}, \ interface_cyoa: {:?}, interface_dia: {:?}, bandwidth: {}, cir: {}, mtu: {}, routing_mode: {:?}, \ -use_onchain_allocation: {}", +use_onchain_allocation: {}, topology_count: {}", self.name, self.loopback_type, self.vlan_id, @@ -69,6 +75,7 @@ use_onchain_allocation: {}", self.mtu, self.routing_mode, self.use_onchain_allocation, + self.topology_count, ) } } @@ -86,7 +93,7 @@ pub fn process_create_device_interface( // Optional: ResourceExtension accounts for onchain allocation (before payer) // Account layout WITH ResourceExtension (use_onchain_allocation = true): - // [device, contributor, globalstate, device_tunnel_block, segment_routing_ids, payer, system] + // [device, contributor, globalstate, device_tunnel_block, segment_routing_ids, topology_0..N, payer, system] // Account layout WITHOUT (legacy, use_onchain_allocation = false): // [device, contributor, globalstate, payer, system] let resource_accounts = if value.use_onchain_allocation { @@ -97,6 +104,12 @@ pub fn process_create_device_interface( None }; + // Read topology PDA accounts (optional, for Vpnv4 loopback flex-algo assignment) + let mut topology_accounts = Vec::new(); + for _ in 0..value.topology_count { + topology_accounts.push(next_account_info(accounts_iter)?); + } + let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -177,6 +190,7 @@ pub fn process_create_device_interface( let mut status = InterfaceStatus::Pending; let mut ip_net = value.ip_net.unwrap_or_default(); let mut node_segment_idx: u16 = 0; + let mut flex_algo_node_segments = Vec::new(); // Atomic create+allocate+activate if onchain allocation is enabled if let Some((device_tunnel_block_ext, segment_routing_ids_ext)) = resource_accounts { @@ -215,6 +229,20 @@ pub fn process_create_device_interface( // Allocate segment routing ID for Vpnv4 loopbacks if value.loopback_type == LoopbackType::Vpnv4 { node_segment_idx = allocate_id(segment_routing_ids_ext)?; + + // Allocate a flex-algo node segment for each topology + for topo_account in &topology_accounts { + assert_eq!( + topo_account.owner, program_id, + "Invalid Topology Account Owner" + ); + assert!(!topo_account.data_is_empty(), "Topology account is empty"); + let topo_segment_idx = allocate_id(segment_routing_ids_ext)?; + flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topo_account.key, + node_segment_idx: topo_segment_idx, + }); + } } status = InterfaceStatus::Activated; @@ -244,7 +272,7 @@ pub fn process_create_device_interface( ip_net, node_segment_idx, user_tunnel_endpoint: value.user_tunnel_endpoint, - flex_algo_node_segments: vec![], + flex_algo_node_segments, }); try_acc_write(&device, device_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs similarity index 81% rename from smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs rename to smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs index d4b27b422..b1218c472 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/assign_node_segments.rs @@ -19,13 +19,16 @@ use solana_program::{ }; #[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] -pub struct TopologyBackfillArgs { +pub struct AssignTopologyNodeSegmentsArgs { 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. +// Keep the old name as a type alias for backward compatibility with existing serialized data. +// The on-wire format is identical (same Borsh layout, same instruction discriminant 110). +pub type TopologyBackfillArgs = AssignTopologyNodeSegmentsArgs; + +/// Assign FlexAlgoNodeSegment entries on Vpnv4 loopbacks for a topology. +/// Idempotent — skips loopbacks that already have an entry for this topology. /// /// Accounts layout: /// [0] topology PDA (readonly — must already exist) @@ -37,10 +40,10 @@ pub struct TopologyBackfillArgs { /// /// Note: payer and system_program are the last two accounts. The SDK client /// always appends them after the variable-length device list. -pub fn process_topology_backfill( +pub fn process_assign_topology_node_segments( program_id: &Pubkey, accounts: &[AccountInfo], - value: &TopologyBackfillArgs, + value: &AssignTopologyNodeSegmentsArgs, ) -> ProgramResult { let accounts_iter = &mut accounts.iter(); @@ -52,7 +55,7 @@ pub fn process_topology_backfill( // system_program at the end, after the variable-length device list. let all_remaining: Vec<&AccountInfo> = accounts_iter.collect(); if all_remaining.len() < 2 { - msg!("TopologyBackfill: expected at least payer and system_program accounts"); + msg!("AssignTopologyNodeSegments: expected at least payer and system_program accounts"); return Err(DoubleZeroError::InvalidArgument.into()); } let payer_account = all_remaining[all_remaining.len() - 2]; @@ -60,10 +63,10 @@ pub fn process_topology_backfill( let device_accounts = &all_remaining[..all_remaining.len() - 2]; #[cfg(test)] - msg!("process_topology_backfill(name={})", value.name); + msg!("process_assign_topology_node_segments(name={})", value.name); if !payer_account.is_signer { - msg!("TopologyBackfill: payer must be a signer"); + msg!("AssignTopologyNodeSegments: payer must be a signer"); return Err(DoubleZeroError::Unauthorized.into()); } @@ -74,13 +77,16 @@ pub fn process_topology_backfill( let (expected_topology_pda, _) = get_topology_pda(program_id, &value.name); if topology_account.key != &expected_topology_pda { msg!( - "TopologyBackfill: invalid topology PDA for name '{}'", + "AssignTopologyNodeSegments: 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); + msg!( + "AssignTopologyNodeSegments: topology '{}' does not exist", + value.name + ); return Err(DoubleZeroError::InvalidArgument.into()); } assert_eq!( @@ -110,7 +116,7 @@ pub fn process_topology_backfill( let globalstate = GlobalState::try_from(globalstate_account)?; if !globalstate.foundation_allowlist.contains(payer_account.key) { - msg!("TopologyBackfill: unauthorized — foundation key required"); + msg!("AssignTopologyNodeSegments: unauthorized — foundation key required"); return Err(DoubleZeroError::Unauthorized.into()); } @@ -120,7 +126,10 @@ pub fn process_topology_backfill( // Allocate new IDs for loopbacks missing this topology's segment. for device_account in device_accounts { - msg!("BackfillTopology: processing device {}", device_account.key); + msg!( + "AssignTopologyNodeSegments: processing device {}", + device_account.key + ); let mut device = Device::try_from(&device_account.data.borrow()[..])?; let mut modified = false; // `interfaces` is the source of truth for `flex_algo_node_segments`. @@ -158,7 +167,7 @@ pub fn process_topology_backfill( } msg!( - "TopologyBackfill: '{}' — {} loopback(s) backfilled, {} already had segment", + "AssignTopologyNodeSegments: '{}' — {} loopback(s) backfilled, {} already had segment", value.name, backfilled_count, skipped_count diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs index b45b525a6..595ef3f13 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs @@ -1,4 +1,4 @@ -pub mod backfill; +pub mod assign_node_segments; pub mod clear; pub mod create; pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index 5b0b7f02e..ee7d127a0 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -411,6 +411,7 @@ async fn test_doublezero_program() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }; execute_transaction( @@ -600,6 +601,7 @@ async fn test_doublezero_program() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }; execute_transaction( diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs index 1057a11c5..8e0ee66da 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs @@ -301,6 +301,7 @@ async fn setup_device_with_interface( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -367,6 +368,7 @@ async fn test_create_loopback_vpnv4_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -445,6 +447,7 @@ async fn test_create_loopback_non_vpnv4_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -527,6 +530,7 @@ async fn test_create_loopback_with_onchain_allocation_honors_supplied_ip_net() { vlan_id: 0, user_tunnel_endpoint: true, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -611,6 +615,7 @@ async fn test_create_physical_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -671,6 +676,7 @@ async fn test_create_interface_backward_compat() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -727,6 +733,7 @@ async fn test_create_interface_feature_flag_disabled() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -799,6 +806,7 @@ async fn test_delete_loopback_with_onchain_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -897,6 +905,7 @@ async fn test_delete_physical_with_onchain_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -994,6 +1003,7 @@ async fn test_delete_interface_backward_compat() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1445,6 +1455,7 @@ async fn test_update_interface_node_segment_idx_duplicate_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index a898bf96d..9f1deec1a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -315,6 +315,7 @@ async fn test_device_interfaces() { vlan_id: 42, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -342,6 +343,7 @@ async fn test_device_interfaces() { vlan_id: 43, user_tunnel_endpoint: true, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -369,6 +371,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -395,6 +398,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -422,6 +426,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -450,6 +455,7 @@ async fn test_device_interfaces() { vlan_id: 1, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -482,6 +488,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -562,6 +569,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1144,6 +1152,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device2_pubkey, false), @@ -1172,6 +1181,7 @@ async fn test_device_interfaces() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device2_pubkey, false), @@ -1623,6 +1633,7 @@ async fn test_interface_create_invalid_mtu_non_cyoa() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1818,6 +1829,7 @@ async fn test_interface_create_invalid_mtu_cyoa() { vlan_id: 0, user_tunnel_endpoint: true, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index fdb00ce95..8cfcabdf7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -275,6 +275,7 @@ async fn test_dzx_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -367,6 +368,7 @@ async fn test_dzx_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ 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 215504b3b..608591f06 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs @@ -184,6 +184,7 @@ async fn test_activate_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -243,6 +244,7 @@ async fn test_activate_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -573,6 +575,7 @@ async fn test_activate_link_legacy_path() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -631,6 +634,7 @@ async fn test_activate_link_legacy_path() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -902,6 +906,7 @@ async fn test_closeaccount_link_with_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -960,6 +965,7 @@ async fn test_closeaccount_link_with_deallocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1308,6 +1314,7 @@ async fn setup_wan_link_infra( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1367,6 +1374,7 @@ async fn setup_wan_link_infra( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2760,6 +2768,7 @@ async fn test_accept_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2819,6 +2828,7 @@ async fn test_accept_link_with_onchain_allocation() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -3107,6 +3117,7 @@ async fn test_accept_link_onchain_allocation_rejects_feature_flag_disabled() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -3165,6 +3176,7 @@ async fn test_accept_link_onchain_allocation_rejects_feature_flag_disabled() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 20d970add..9331a3ecc 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -238,6 +238,7 @@ async fn test_wan_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -369,6 +370,7 @@ async fn test_wan_link() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1064,6 +1066,7 @@ async fn test_wan_link_rejects_cyoa_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1157,6 +1160,7 @@ async fn test_wan_link_rejects_cyoa_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1625,6 +1629,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -1716,6 +1721,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2090,6 +2096,7 @@ async fn setup_link_env() -> ( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2178,6 +2185,7 @@ async fn setup_link_env() -> ( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2826,6 +2834,7 @@ async fn test_link_activation_succeeds_without_unicast_default() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ @@ -2914,6 +2923,7 @@ async fn test_link_activation_succeeds_without_unicast_default() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }, ), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs b/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs index ee1c2c4cc..1f5dd4187 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/resource_extension_test.rs @@ -1466,6 +1466,7 @@ async fn create_loopback_interface( ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, interface_cyoa: InterfaceCYOA::None, interface_dia: InterfaceDIA::None, bandwidth: 0, diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs index fb826a019..882bd7020 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -21,8 +21,8 @@ use doublezero_serviceability::{ link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, location::create::LocationCreateArgs, topology::{ - backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, - delete::TopologyDeleteArgs, + assign_node_segments::AssignTopologyNodeSegmentsArgs, clear::TopologyClearArgs, + create::TopologyCreateArgs, delete::TopologyDeleteArgs, }, }, resource::ResourceType, @@ -675,6 +675,7 @@ async fn setup_wan_link( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -748,6 +749,7 @@ async fn setup_wan_link( vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -1663,6 +1665,7 @@ async fn test_topology_backfill_populates_vpnv4_loopbacks() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -1709,7 +1712,7 @@ async fn test_topology_backfill_populates_vpnv4_loopbacks() { ]; let mut tx = create_transaction( program_id, - &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + &DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), &backfill_accounts, @@ -1738,7 +1741,7 @@ async fn test_topology_backfill_populates_vpnv4_loopbacks() { let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; let mut tx2 = create_transaction( program_id, - &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + &DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), &backfill_accounts, @@ -1798,7 +1801,7 @@ async fn test_topology_backfill_non_foundation_rejected() { &mut banks_client, recent_blockhash, program_id, - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), vec![ @@ -1840,7 +1843,7 @@ async fn test_topology_backfill_nonexistent_topology_rejected() { &mut banks_client, recent_blockhash, program_id, - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "does-not-exist".to_string(), }), vec![ @@ -2037,6 +2040,7 @@ async fn test_topology_backfill_allocates_sr_id_from_onchain_resource() { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), @@ -2088,7 +2092,7 @@ async fn test_topology_backfill_allocates_sr_id_from_onchain_resource() { ]; let mut tx = create_transaction( program_id, - &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + &DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: "unicast-default".to_string(), }), &backfill_accounts, 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 5cec68dda..f6c032871 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs @@ -193,6 +193,7 @@ async fn setup_two_devices_with_link() -> ( ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -269,6 +270,7 @@ async fn setup_two_devices_with_link() -> ( ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -545,6 +547,7 @@ async fn test_unlink_from_pending() { ip_net: None, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/device/interface/create.rs b/smartcontract/sdk/rs/src/commands/device/interface/create.rs index 4fdd464e3..0e63d8b3c 100644 --- a/smartcontract/sdk/rs/src/commands/device/interface/create.rs +++ b/smartcontract/sdk/rs/src/commands/device/interface/create.rs @@ -5,7 +5,7 @@ use crate::{ use doublezero_program_common::types::network_v4::NetworkV4; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::get_resource_extension_pda, + pda::{get_resource_extension_pda, get_topology_pda}, processors::device::interface::create::DeviceInterfaceCreateArgs, resource::ResourceType, state::{ @@ -29,6 +29,9 @@ pub struct CreateDeviceInterfaceCommand { pub routing_mode: RoutingMode, pub vlan_id: u16, pub user_tunnel_endpoint: bool, + /// Topology names to assign flex-algo node segments for (Vpnv4 loopbacks only). + /// Empty means no topology assignment. + pub topology_names: Vec, } impl CreateDeviceInterfaceCommand { @@ -51,6 +54,7 @@ impl CreateDeviceInterfaceCommand { AccountMeta::new(globalstate_pubkey, false), ]; + let mut topology_count: u8 = 0; if use_onchain_allocation { let (device_tunnel_block_ext, _, _) = get_resource_extension_pda( &client.get_program_id(), @@ -62,6 +66,16 @@ impl CreateDeviceInterfaceCommand { ); accounts.push(AccountMeta::new(device_tunnel_block_ext, false)); accounts.push(AccountMeta::new(segment_routing_ids_ext, false)); + + // For Vpnv4 loopbacks, append topology PDAs so the onchain program + // can allocate FlexAlgoNodeSegment entries atomically. + if self.loopback_type == LoopbackType::Vpnv4 { + for name in &self.topology_names { + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), name); + accounts.push(AccountMeta::new_readonly(topology_pda, false)); + topology_count += 1; + } + } } client @@ -79,6 +93,7 @@ impl CreateDeviceInterfaceCommand { vlan_id: self.vlan_id, user_tunnel_endpoint: self.user_tunnel_endpoint, use_onchain_allocation, + topology_count, }), accounts, ) @@ -167,6 +182,7 @@ mod tests { vlan_id: 100, user_tunnel_endpoint: true, use_onchain_allocation: false, + topology_count: 0, }, )), predicate::eq(vec![ @@ -190,6 +206,7 @@ mod tests { routing_mode: RoutingMode::Static, vlan_id: 100, user_tunnel_endpoint: true, + topology_names: vec![], }; let res = command.execute(&client); @@ -258,6 +275,7 @@ mod tests { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: true, + topology_count: 0, }, )), predicate::eq(vec![ @@ -283,6 +301,7 @@ mod tests { routing_mode: RoutingMode::Static, vlan_id: 0, user_tunnel_endpoint: false, + topology_names: vec![], } .execute(&client); diff --git a/smartcontract/sdk/rs/src/commands/topology/backfill.rs b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs similarity index 85% rename from smartcontract/sdk/rs/src/commands/topology/backfill.rs rename to smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs index 23ae8ded3..4e5be0958 100644 --- a/smartcontract/sdk/rs/src/commands/topology/backfill.rs +++ b/smartcontract/sdk/rs/src/commands/topology/assign_node_segments.rs @@ -2,7 +2,7 @@ use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient} use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{get_resource_extension_pda, get_topology_pda}, - processors::topology::backfill::TopologyBackfillArgs, + processors::topology::assign_node_segments::AssignTopologyNodeSegmentsArgs, resource::ResourceType, }; use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; @@ -13,12 +13,12 @@ use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature} pub const BACKFILL_BATCH_SIZE: usize = 16; #[derive(Debug, PartialEq, Clone)] -pub struct BackfillTopologyCommand { +pub struct AssignTopologyNodeSegmentsCommand { pub name: String, pub device_pubkeys: Vec, } -impl BackfillTopologyCommand { +impl AssignTopologyNodeSegmentsCommand { pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result> { let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand .execute(client) @@ -42,7 +42,7 @@ impl BackfillTopologyCommand { } let sig = client.execute_transaction( - DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { name: self.name.clone(), }), accounts, @@ -57,14 +57,16 @@ impl BackfillTopologyCommand { #[cfg(test)] mod tests { use crate::{ - commands::topology::backfill::{BackfillTopologyCommand, BACKFILL_BATCH_SIZE}, + commands::topology::assign_node_segments::{ + AssignTopologyNodeSegmentsCommand, BACKFILL_BATCH_SIZE, + }, 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, + processors::topology::assign_node_segments::AssignTopologyNodeSegmentsArgs, resource::ResourceType, }; use mockall::{predicate, Sequence}; @@ -74,7 +76,7 @@ mod tests { fn test_commands_topology_backfill_no_devices_sends_no_tx() { let client = create_test_client(); - let res = BackfillTopologyCommand { + let res = AssignTopologyNodeSegmentsCommand { name: "unicast-default".to_string(), device_pubkeys: vec![], } @@ -97,8 +99,8 @@ mod tests { client .expect_execute_transaction() .with( - predicate::eq(DoubleZeroInstruction::BackfillTopology( - TopologyBackfillArgs { + predicate::eq(DoubleZeroInstruction::AssignTopologyNodeSegments( + AssignTopologyNodeSegmentsArgs { name: "algo128".to_string(), }, )), @@ -112,7 +114,7 @@ mod tests { ) .returning(|_, _| Ok(Signature::new_unique())); - let res = BackfillTopologyCommand { + let res = AssignTopologyNodeSegmentsCommand { name: "algo128".to_string(), device_pubkeys: vec![device1, device2], } @@ -138,9 +140,10 @@ mod tests { AccountMeta::new_readonly(globalstate_pubkey, false), ]; - let expected_args = DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { - name: "algo128".to_string(), - }); + let expected_args = + DoubleZeroInstruction::AssignTopologyNodeSegments(AssignTopologyNodeSegmentsArgs { + name: "algo128".to_string(), + }); let mut seq = Sequence::new(); for chunk in devices.chunks(BACKFILL_BATCH_SIZE) { @@ -159,7 +162,7 @@ mod tests { .returning(|_, _| Ok(Signature::new_unique())); } - let res = BackfillTopologyCommand { + let res = AssignTopologyNodeSegmentsCommand { name: "algo128".to_string(), device_pubkeys: devices, } diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs index 75df31d73..e2083ed27 100644 --- a/smartcontract/sdk/rs/src/commands/topology/create.rs +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -1,7 +1,7 @@ use crate::{ commands::{ device::list::ListDeviceCommand, globalstate::get::GetGlobalStateCommand, - topology::backfill::BackfillTopologyCommand, + topology::assign_node_segments::AssignTopologyNodeSegmentsCommand, }, DoubleZeroClient, }; @@ -74,7 +74,7 @@ impl CreateTopologyCommand { .collect(); device_pubkeys.sort(); - let backfill_signatures = BackfillTopologyCommand { + let backfill_signatures = AssignTopologyNodeSegmentsCommand { name: self.name.clone(), device_pubkeys, } @@ -99,7 +99,9 @@ mod tests { use doublezero_serviceability::{ instructions::DoubleZeroInstruction, pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, - processors::topology::{backfill::TopologyBackfillArgs, create::TopologyCreateArgs}, + processors::topology::{ + assign_node_segments::AssignTopologyNodeSegmentsArgs, create::TopologyCreateArgs, + }, resource::ResourceType, state::{ accountdata::AccountData, @@ -228,8 +230,8 @@ mod tests { .times(1) .in_sequence(&mut seq) .with( - predicate::eq(DoubleZeroInstruction::BackfillTopology( - TopologyBackfillArgs { + predicate::eq(DoubleZeroInstruction::AssignTopologyNodeSegments( + AssignTopologyNodeSegmentsArgs { name: "algo128".to_string(), }, )), diff --git a/smartcontract/sdk/rs/src/commands/topology/mod.rs b/smartcontract/sdk/rs/src/commands/topology/mod.rs index 01fa6fd76..43f51edd1 100644 --- a/smartcontract/sdk/rs/src/commands/topology/mod.rs +++ b/smartcontract/sdk/rs/src/commands/topology/mod.rs @@ -1,4 +1,4 @@ -pub mod backfill; +pub mod assign_node_segments; pub mod clear; pub mod create; pub mod delete; From c5758d6f1dfdfdd777c8cce6b8be64e7d9a27236 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Fri, 1 May 2026 18:18:00 -0500 Subject: [PATCH 3/5] smartcontract: remove unused import and fix topology_count in test files --- .../src/processors/device/interface/create.rs | 2 +- .../programs/doublezero-telemetry/tests/test_helpers.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 80e6ea09f..c3df9ae86 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs @@ -1,6 +1,6 @@ use crate::{ error::DoubleZeroError, - pda::{get_resource_extension_pda, get_topology_pda}, + pda::get_resource_extension_pda, processors::{ resource::{allocate_id, allocate_ip}, validation::validate_program_account, diff --git a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs index 1e8cad223..4f02e052c 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs @@ -1128,6 +1128,7 @@ impl ServiceabilityProgramHelper { vlan_id: 0, user_tunnel_endpoint: false, use_onchain_allocation: false, + topology_count: 0, }), vec![ AccountMeta::new(device_pk, false), From 99468eb7a03c5cbb18fc7b0f17c40e5471a0bb62 Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Fri, 8 May 2026 21:08:30 +0000 Subject: [PATCH 4/5] pr fixes --- smartcontract/cli/src/device/interface/get.rs | 182 ++++- .../cli/src/device/interface/update.rs | 23 + .../src/instructions.rs | 4 +- .../src/processors/device/interface/delete.rs | 5 + .../src/processors/device/interface/update.rs | 102 ++- .../interface_onchain_allocation_test.rs | 764 +++++++++++++++++- .../tests/interface_test.rs | 2 + .../tests/link_wan_test.rs | 12 + .../src/commands/device/interface/update.rs | 135 +++- 9 files changed, 1199 insertions(+), 30 deletions(-) diff --git a/smartcontract/cli/src/device/interface/get.rs b/smartcontract/cli/src/device/interface/get.rs index 8cbbfc456..23a481a27 100644 --- a/smartcontract/cli/src/device/interface/get.rs +++ b/smartcontract/cli/src/device/interface/get.rs @@ -1,7 +1,9 @@ use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; use clap::Args; use doublezero_program_common::validate_iface; -use doublezero_sdk::commands::device::get::GetDeviceCommand; +use doublezero_sdk::commands::{ + device::get::GetDeviceCommand, topology::list::ListTopologyCommand, +}; use serde::Serialize; use std::io::Write; use tabled::Tabled; @@ -33,6 +35,7 @@ struct InterfaceDisplay { pub ip_net: String, pub node_segment_idx: u16, pub user_tunnel_endpoint: bool, + pub flex_algo_node_segments: String, pub device_pk: String, } @@ -48,6 +51,31 @@ impl GetDeviceInterfaceCliCommand { .find(|i| i.name.to_lowercase() == self.name.to_lowercase()) .ok_or_else(|| eyre::eyre!("Interface '{}' not found", self.name))?; + // Resolve flex-algo topology PDAs to names. If the lookup fails (e.g. the + // topology was deleted), fall back to a truncated pubkey. + let topology_name_for = |pk: &solana_sdk::pubkey::Pubkey| -> Option { + client + .list_topology(ListTopologyCommand) + .ok() + .and_then(|m| m.get(pk).map(|t| t.name.clone())) + }; + let flex_algo_node_segments = if interface.flex_algo_node_segments.is_empty() { + String::new() + } else { + interface + .flex_algo_node_segments + .iter() + .map(|seg| { + let label = topology_name_for(&seg.topology).unwrap_or_else(|| { + let s = seg.topology.to_string(); + format!("{}…", &s[..8.min(s.len())]) + }); + format!("{}:{}", label, seg.node_segment_idx) + }) + .collect::>() + .join("\n") + }; + let display = InterfaceDisplay { name: interface.name.clone(), status: interface.status.to_string(), @@ -61,6 +89,7 @@ impl GetDeviceInterfaceCliCommand { ip_net: interface.ip_net.to_string(), node_segment_idx: interface.node_segment_idx, user_tunnel_endpoint: interface.user_tunnel_endpoint, + flex_algo_node_segments, device_pk: device_pk.to_string(), }; @@ -71,8 +100,15 @@ impl GetDeviceInterfaceCliCommand { let headers = InterfaceDisplay::headers(); let fields = display.fields(); let max_len = headers.iter().map(|h| h.len()).max().unwrap_or(0); + let blank = String::new(); for (header, value) in headers.iter().zip(fields.iter()) { - writeln!(out, " {header:, + /// Reconcile flex-algo topologies on a Vpnv4 loopback. Omit to leave the + /// existing set untouched. Pass an empty value (`--topologies ""`) to + /// clear all flex-algo segments. Pass a comma-separated list of topology + /// names (or repeat the flag) to set the desired set; entries that aren't + /// in the new set have their segment routing IDs deallocated, additions + /// get freshly allocated IDs, and unchanged entries keep their IDs. + #[arg(long, value_delimiter = ',', num_args = 0..)] + pub topologies: Option>, /// Wait for the device interface to be activated #[arg(short, long, default_value_t = false)] pub wait: bool, @@ -163,6 +171,16 @@ impl UpdateDeviceInterfaceCliCommand { .transpose() .map_err(|e| eyre::eyre!("Invalid status: {}", e))?; + // clap's `num_args = 0..` produces Some(vec!["", ...]) for `--topologies ""`, + // so strip empty/whitespace-only entries. + let topology_names = self.topologies.as_ref().map(|names| { + names + .iter() + .filter(|n| !n.trim().is_empty()) + .map(|n| n.trim().to_string()) + .collect::>() + }); + let signature = client.update_device_interface(UpdateDeviceInterfaceCommand { pubkey: device_pk, name: self.name.clone(), @@ -178,6 +196,7 @@ impl UpdateDeviceInterfaceCliCommand { status: parsed_status, ip_net: parsed_ip_net, node_segment_idx: self.node_segment_idx, + topology_names, })?; writeln!(out, "Signature: {signature}")?; @@ -310,6 +329,7 @@ mod tests { status: Some(InterfaceStatus::Activated), ip_net: Some("10.0.1.1/24".parse().unwrap()), node_segment_idx: None, + topology_names: None, })) .times(1) .returning(move |_| Ok(signature)); @@ -331,6 +351,7 @@ mod tests { status: Some(InterfaceStatus::Activated.to_string()), ip_net: Some("10.0.1.1/24".to_string()), node_segment_idx: None, + topologies: None, wait: false, } .execute(&client, &mut output); @@ -470,6 +491,7 @@ mod tests { status: None, ip_net: Some("185.189.47.80/32".to_string()), node_segment_idx: None, + topologies: None, wait: false, } .execute(&client, &mut output); @@ -566,6 +588,7 @@ mod tests { status: None, ip_net: None, node_segment_idx: None, + topologies: None, wait: false, } .execute(&client, &mut output); diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index e306eb75c..3ef454866 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -519,7 +519,7 @@ impl DoubleZeroInstruction { Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 Self::AssignTopologyNodeSegments(_) => "AssignTopologyNodeSegments".to_string(), // variant 110 - Self::Deprecated111() => "Deprecated111".to_string(), // variant 111 + Self::Deprecated111() => "Deprecated111".to_string(), // variant 111 } } @@ -1236,6 +1236,8 @@ mod tests { ip_net: Some("10.0.0.0/3".parse().unwrap()), node_segment_idx: Some(1), status: None, + topology_count: 0, + update_topologies: false, }), "UpdateDeviceInterface", ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs index 68b6f4610..fc31a95fb 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/delete.rs @@ -163,6 +163,11 @@ pub fn process_delete_device_interface( // Deallocate node_segment_idx if it was allocated (only for Vpnv4 loopbacks) if iface.loopback_type == LoopbackType::Vpnv4 && iface.node_segment_idx != 0 { deallocate_id(segment_routing_ids_ext, iface.node_segment_idx); + + // Deallocate flex-algo node segments allocated alongside the loopback + for entry in &iface.flex_algo_node_segments { + deallocate_id(segment_routing_ids_ext, entry.node_segment_idx); + } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs index f017f5a44..a566d041c 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs @@ -4,7 +4,7 @@ use crate::{ helper::format_option_displayable, pda::get_resource_extension_pda, processors::{ - resource::{allocate_specific_id, deallocate_id}, + resource::{allocate_id, allocate_specific_id, deallocate_id}, validation::validate_program_account, }, resource::ResourceType, @@ -19,6 +19,7 @@ use crate::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, CYOA_DIA_INTERFACE_MTU, INTERFACE_MTU, }, + topology::FlexAlgoNodeSegment, }, }; use borsh::BorshSerialize; @@ -32,6 +33,7 @@ use solana_program::{ entrypoint::ProgramResult, pubkey::Pubkey, }; +use std::collections::HashSet; #[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] pub struct DeviceInterfaceUpdateArgs { @@ -48,6 +50,15 @@ pub struct DeviceInterfaceUpdateArgs { pub cir: Option, pub mtu: Option, pub routing_mode: Option, + /// Number of topology PDA accounts appended after segment_routing_ids. + /// Only consumed when update_topologies is true. + #[incremental(default = 0)] + pub topology_count: u8, + /// When true, the variadic topology accounts represent the desired set of + /// flex-algo topologies; the processor reconciles flex_algo_node_segments + /// (deallocate removed, allocate added). Only valid on Vpnv4 loopbacks. + #[incremental(default = false)] + pub update_topologies: bool, } impl fmt::Debug for DeviceInterfaceUpdateArgs { @@ -56,7 +67,7 @@ impl fmt::Debug for DeviceInterfaceUpdateArgs { f, "name: {}, loopback_type: {}, vlan_id: {}, user_tunnel_endpoint: {}, status: {}, \ ip_net: {}, node_segment_idx: {}, interface_cyoa: {}, interface_dia: {}, bandwidth: {}, \ -cir: {}, mtu: {}, routing_mode: {}", +cir: {}, mtu: {}, routing_mode: {}, topology_count: {}, update_topologies: {}", self.name, format_option!(self.loopback_type), format_option!(self.vlan_id), @@ -70,6 +81,8 @@ cir: {}, mtu: {}, routing_mode: {}", format_option!(self.cir), format_option!(self.mtu), format_option!(self.routing_mode), + self.topology_count, + self.update_topologies, ) } } @@ -85,18 +98,32 @@ pub fn process_update_device_interface( let contributor_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; - // Optional: SegmentRoutingIds resource extension account (when node_segment_idx - // is being updated with onchain allocation enabled). - // Account layout WITH onchain allocation (node_segment_idx is Some): + // Optional: SegmentRoutingIds resource extension account, present when + // node_segment_idx is being updated under onchain allocation, OR when + // reconciling flex-algo topologies. + // Account layout when reconciling topologies: + // [device, contributor, globalstate, segment_routing_ids_ext, topo_0..N, payer, system] + // Account layout for node_segment_idx under onchain allocation: // [device, contributor, globalstate, segment_routing_ids_ext, payer, system] // Account layout WITHOUT (legacy): // [device, contributor, globalstate, payer, system] - let segment_routing_ids_ext = if accounts.len() > 5 { + // + // The presence of update_topologies forces seg_ext consumption; otherwise + // fall back to the legacy account-count heuristic so callers that set + // node_segment_idx without onchain allocation enabled still work. + let segment_routing_ids_ext = if value.update_topologies || accounts.len() > 5 { Some(next_account_info(accounts_iter)?) } else { None }; + let mut topology_accounts = Vec::new(); + if value.update_topologies { + for _ in 0..value.topology_count { + topology_accounts.push(next_account_info(accounts_iter)?); + } + } + let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -226,6 +253,69 @@ pub fn process_update_device_interface( iface.node_segment_idx = node_segment_idx; } + // Reconcile flex-algo node segments against the desired topology set. + // Existing entries whose topology is still in the set keep their SR ID; entries + // for removed topologies have their SR ID deallocated; new topologies get a + // freshly allocated SR ID. + if value.update_topologies { + if !globalstate.foundation_allowlist.contains(payer_account.key) { + return Err(DoubleZeroError::NotAllowed.into()); + } + + if iface.loopback_type != LoopbackType::Vpnv4 { + return Err(DoubleZeroError::InvalidArgument.into()); + } + + if !is_feature_enabled(globalstate.feature_flags, FeatureFlag::OnChainAllocation) { + return Err(DoubleZeroError::FeatureNotEnabled.into()); + } + + let seg_ext = segment_routing_ids_ext.ok_or(DoubleZeroError::InvalidArgument)?; + + let (expected_seg_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + validate_program_account!( + seg_ext, + program_id, + writable = true, + pda = &expected_seg_pda, + "SegmentRoutingIds" + ); + + let mut desired: HashSet = HashSet::new(); + for topo_account in &topology_accounts { + assert_eq!( + topo_account.owner, program_id, + "Invalid Topology Account Owner" + ); + assert!(!topo_account.data_is_empty(), "Topology account is empty"); + if !desired.insert(*topo_account.key) { + return Err(DoubleZeroError::InvalidArgument.into()); + } + } + + let mut kept: Vec = + Vec::with_capacity(iface.flex_algo_node_segments.len()); + for entry in iface.flex_algo_node_segments.drain(..) { + if desired.contains(&entry.topology) { + kept.push(entry); + } else { + deallocate_id(seg_ext, entry.node_segment_idx); + } + } + let current: HashSet = kept.iter().map(|e| e.topology).collect(); + for topology in &desired { + if !current.contains(topology) { + let node_segment_idx = allocate_id(seg_ext)?; + kept.push(FlexAlgoNodeSegment { + topology: *topology, + node_segment_idx, + }); + } + } + iface.flex_algo_node_segments = kept; + } + // CYOA interfaces must have an ip_net — prevent setting CYOA without ip_net // or clearing ip_net from a CYOA interface via update if iface.interface_cyoa != InterfaceCYOA::None && iface.ip_net == NetworkV4::default() { diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs index 8e0ee66da..f18f8764b 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs @@ -23,6 +23,7 @@ use doublezero_serviceability::{ exchange::create::ExchangeCreateArgs, globalstate::setfeatureflags::SetFeatureFlagsArgs, location::create::LocationCreateArgs, + topology::create::TopologyCreateArgs, }, resource::{IdOrIp, ResourceType}, state::{ @@ -31,12 +32,14 @@ use doublezero_serviceability::{ interface::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, + topology::TopologyConstraint, }, }; use solana_program::instruction::InstructionError; use solana_program_test::*; use solana_sdk::{ - instruction::AccountMeta, pubkey::Pubkey, signer::Signer, transaction::TransactionError, + instruction::AccountMeta, pubkey::Pubkey, signature::Keypair, signer::Signer, + transaction::TransactionError, }; mod test_helpers; @@ -1519,3 +1522,762 @@ async fn test_update_interface_node_segment_idx_duplicate_allocation() { _ => panic!("Unexpected error type: {:?}", err), } } + +// ============================================================================= +// Flex-algo node segment tests (create + delete + update reconciliation) +// ============================================================================= + +/// Helper: create a topology with the given name and return its PDA. +async fn create_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + payer: &Keypair, +) -> Pubkey { + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + 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: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; + topology_pda +} + +/// Test: deleting a Vpnv4 loopback that has flex-algo node segments +/// deallocates each segment's SR ID alongside the primary node_segment_idx. +#[tokio::test] +async fn test_delete_loopback_deallocates_flex_algo_segments() { + 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(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetFeatureFlags(SetFeatureFlagsArgs { + feature_flags: FeatureFlag::OnChainAllocation.to_mask(), + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey) = + setup_device(&mut banks_client, &payer, program_id, globalstate_pubkey).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Atomic create with 2 topologies: allocates primary + 2 flex-algo SR IDs. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback0".to_string(), + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: true, + topology_count: 2, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].clone(); + assert_eq!(iface.flex_algo_node_segments.len(), 2); + let primary = iface.node_segment_idx; + let flex_a = iface.flex_algo_node_segments[0].node_segment_idx; + let flex_b = iface.flex_algo_node_segments[1].node_segment_idx; + let resource = get_resource_extension_data(&mut banks_client, segment_routing_ids_pda) + .await + .expect("SegmentRoutingIds resource not found"); + let allocated = resource.iter_allocated(); + assert!(allocated.contains(&IdOrIp::Id(primary))); + assert!(allocated.contains(&IdOrIp::Id(flex_a))); + assert!(allocated.contains(&IdOrIp::Id(flex_b))); + + // Atomic delete should deallocate primary AND each flex-algo segment. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteDeviceInterface(DeviceInterfaceDeleteArgs { + name: "Loopback0".to_string(), + use_onchain_deallocation: true, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + assert_eq!(device.interfaces.len(), 0); + + let resource = get_resource_extension_data(&mut banks_client, segment_routing_ids_pda) + .await + .expect("SegmentRoutingIds resource not found"); + let allocated = resource.iter_allocated(); + for id in [primary, flex_a, flex_b] { + assert!( + !allocated.contains(&IdOrIp::Id(id)), + "SR id {id} should be deallocated after delete" + ); + } +} + +/// Helper: create a Vpnv4 loopback with `initial_topologies` and return +/// (device_pubkey, contributor_pubkey, segment_routing_ids_pda, device_tunnel_block_pda). +async fn setup_vpnv4_loopback_with_topologies( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + payer: &Keypair, + initial_topologies: &[Pubkey], +) -> (Pubkey, Pubkey, Pubkey, Pubkey) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (device_pubkey, contributor_pubkey) = + setup_device(banks_client, payer, program_id, globalstate_pubkey).await; + let (device_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + let mut accounts = vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + ]; + for t in initial_topologies { + accounts.push(AccountMeta::new_readonly(*t, false)); + } + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback0".to_string(), + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: true, + topology_count: initial_topologies.len() as u8, + }), + accounts, + payer, + ) + .await; + + ( + device_pubkey, + contributor_pubkey, + segment_routing_ids_pda, + device_tunnel_block_pda, + ) +} + +async fn enable_onchain_allocation( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + payer: &Keypair, +) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetFeatureFlags(SetFeatureFlagsArgs { + feature_flags: FeatureFlag::OnChainAllocation.to_mask(), + }), + vec![AccountMeta::new(globalstate_pubkey, false)], + payer, + ) + .await; +} + +fn update_topologies_args(name: &str, count: u8) -> DeviceInterfaceUpdateArgs { + DeviceInterfaceUpdateArgs { + name: name.to_string(), + topology_count: count, + update_topologies: true, + ..Default::default() + } +} + +fn topology_set(iface: &doublezero_serviceability::state::interface::Interface) -> Vec { + let mut s: Vec = iface + .flex_algo_node_segments + .iter() + .map(|e| e.topology) + .collect(); + s.sort(); + s +} + +/// Test: update_topologies adds a new topology while preserving the existing one's SR ID. +#[tokio::test] +async fn test_update_topologies_adds_segment() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let iface_before = device.interfaces[0].clone(); + let topo_a_idx = iface_before.flex_algo_node_segments[0].node_segment_idx; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let iface = &device.interfaces[0]; + let mut expected = vec![topo_a, topo_b]; + expected.sort(); + assert_eq!(topology_set(iface), expected); + let entry_a = iface + .flex_algo_node_segments + .iter() + .find(|e| e.topology == topo_a) + .unwrap(); + assert_eq!( + entry_a.node_segment_idx, topo_a_idx, + "topo-a SR ID should be unchanged" + ); +} + +/// Test: update_topologies removes a topology and deallocates its SR ID. +#[tokio::test] +async fn test_update_topologies_removes_segment() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a, topo_b], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let removed_idx = device.interfaces[0] + .flex_algo_node_segments + .iter() + .find(|e| e.topology == topo_b) + .unwrap() + .node_segment_idx; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 1)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + assert_eq!(topology_set(&device.interfaces[0]), vec![topo_a]); + + let resource = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap(); + assert!( + !resource.iter_allocated().contains(&IdOrIp::Id(removed_idx)), + "removed topology's SR id {removed_idx} should be deallocated" + ); +} + +/// Test: update_topologies clears all flex-algo segments when an empty set is passed. +#[tokio::test] +async fn test_update_topologies_clear() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a, topo_b], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let initial_ids: Vec = device.interfaces[0] + .flex_algo_node_segments + .iter() + .map(|e| e.node_segment_idx) + .collect(); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 0)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + assert!(device.interfaces[0].flex_algo_node_segments.is_empty()); + + let resource = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap(); + let allocated = resource.iter_allocated(); + for id in initial_ids { + assert!( + !allocated.contains(&IdOrIp::Id(id)), + "all flex-algo SR ids should be deallocated after clear" + ); + } +} + +/// Test: update_topologies with the same set is a no-op (no SR id churn). +#[tokio::test] +async fn test_update_topologies_no_op() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a, topo_b], + ) + .await; + + let before = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let before_segs = before.interfaces[0].flex_algo_node_segments.clone(); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let after = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let after_segs = after.interfaces[0].flex_algo_node_segments.clone(); + let mut before_sorted = before_segs; + before_sorted.sort_by_key(|e| e.topology); + let mut after_sorted = after_segs; + after_sorted.sort_by_key(|e| e.topology); + assert_eq!(before_sorted, after_sorted, "no SR id churn expected"); +} + +/// Test: update_topologies replaces the full set (swap A → B). +#[tokio::test] +async fn test_update_topologies_full_swap() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a], + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let primary_idx = device.interfaces[0].node_segment_idx; + let allocated_before = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap() + .iter_allocated() + .len(); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 1)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_b, false), + ], + &payer, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let iface = &device.interfaces[0]; + assert_eq!(topology_set(iface), vec![topo_b]); + // Primary SR id should be untouched. + assert_eq!(iface.node_segment_idx, primary_idx); + // The total number of allocated SR ids should be unchanged: one removed + // (topo-a), one added (topo-b). Whether the new allocation reuses the just- + // freed slot is an implementation detail we don't pin down here. + let allocated_after = get_resource_extension_data(&mut banks_client, seg_routing_pda) + .await + .unwrap() + .iter_allocated() + .len(); + assert_eq!(allocated_before, allocated_after); +} + +/// Test: update_topologies on a non-Vpnv4 interface is rejected. +#[tokio::test] +async fn test_update_topologies_rejects_non_vpnv4() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let (device_pubkey, contributor_pubkey) = + setup_device(&mut banks_client, &payer, program_id, globalstate_pubkey).await; + let (device_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Create an Ipv4 (non-Vpnv4) loopback. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback0".to_string(), + loopback_type: LoopbackType::Ipv4, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: true, + topology_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + ], + &payer, + ) + .await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .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::UpdateDeviceInterface(update_topologies_args("Loopback0", 1)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(topo_a, false), + ], + &payer, + ) + .await; + + let err = result.expect_err("expected non-Vpnv4 update_topologies to fail"); + match err { + BanksClientError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(code), + )) => { + assert_eq!(DoubleZeroError::InvalidArgument, code.into()); + } + _ => panic!("Unexpected error: {err:?}"), + } +} + +/// Test: passing the same topology twice is rejected as InvalidArgument. +#[tokio::test] +async fn test_update_topologies_rejects_duplicates() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[], + ) + .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::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_a, false), + ], + &payer, + ) + .await; + + let err = result.expect_err("expected duplicate topology rejection"); + match err { + BanksClientError::TransactionError(TransactionError::InstructionError( + _, + InstructionError::Custom(code), + )) => { + assert_eq!(DoubleZeroError::InvalidArgument, code.into()); + } + _ => panic!("Unexpected error: {err:?}"), + } +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index 9f1deec1a..386561e7a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -608,6 +608,8 @@ async fn test_device_interfaces() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 9331a3ecc..fcf1977d0 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -1265,6 +1265,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -1293,6 +1295,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -1358,6 +1362,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_z_pubkey, false), @@ -1416,6 +1422,8 @@ async fn test_wan_link_rejects_cyoa_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -1855,6 +1863,8 @@ async fn test_cannot_set_cyoa_on_linked_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_a_pubkey, false), @@ -1891,6 +1901,8 @@ async fn test_cannot_set_cyoa_on_linked_interface() { status: None, ip_net: None, node_segment_idx: None, + topology_count: 0, + update_topologies: false, }), vec![ AccountMeta::new(device_z_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/device/interface/update.rs b/smartcontract/sdk/rs/src/commands/device/interface/update.rs index 186a6974e..6f02bfc71 100644 --- a/smartcontract/sdk/rs/src/commands/device/interface/update.rs +++ b/smartcontract/sdk/rs/src/commands/device/interface/update.rs @@ -5,7 +5,7 @@ use crate::{ use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::get_resource_extension_pda, + pda::{get_resource_extension_pda, get_topology_pda}, processors::device::interface::DeviceInterfaceUpdateArgs, resource::ResourceType, state::{ @@ -31,6 +31,10 @@ pub struct UpdateDeviceInterfaceCommand { pub status: Option, pub ip_net: Option, pub node_segment_idx: Option, + /// Reconcile flex-algo topology set on a Vpnv4 loopback. None leaves it + /// alone; Some(vec![]) clears all entries; Some(names) sets exactly that + /// set. Names are resolved to Topology PDAs via get_topology_pda. + pub topology_names: Option>, } impl UpdateDeviceInterfaceCommand { @@ -50,11 +54,13 @@ impl UpdateDeviceInterfaceCommand { AccountMeta::new(globalstate_pubkey, false), ]; - // Include SegmentRoutingIds resource account when updating node_segment_idx - // with onchain allocation enabled - if self.node_segment_idx.is_some() - && is_feature_enabled(globalstate.feature_flags, FeatureFlag::OnChainAllocation) - { + let onchain_allocation_enabled = + is_feature_enabled(globalstate.feature_flags, FeatureFlag::OnChainAllocation); + let update_topologies = self.topology_names.is_some() && onchain_allocation_enabled; + let needs_seg_ext = + (self.node_segment_idx.is_some() && onchain_allocation_enabled) || update_topologies; + + if needs_seg_ext { let (seg_routing_pda, _, _) = get_resource_extension_pda( &client.get_program_id(), ResourceType::SegmentRoutingIds, @@ -62,6 +68,15 @@ impl UpdateDeviceInterfaceCommand { accounts.push(AccountMeta::new(seg_routing_pda, false)); } + let mut topology_count: u8 = 0; + if update_topologies { + for name in self.topology_names.as_ref().unwrap() { + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), name); + accounts.push(AccountMeta::new_readonly(topology_pda, false)); + topology_count += 1; + } + } + client.execute_transaction( DoubleZeroInstruction::UpdateDeviceInterface(DeviceInterfaceUpdateArgs { name: self.name.clone(), @@ -77,6 +92,8 @@ impl UpdateDeviceInterfaceCommand { status: self.status, ip_net: self.ip_net, node_segment_idx: self.node_segment_idx, + topology_count, + update_topologies, }), accounts, ) @@ -153,18 +170,8 @@ mod tests { predicate::eq(DoubleZeroInstruction::UpdateDeviceInterface( DeviceInterfaceUpdateArgs { name: "Ethernet0".to_string(), - loopback_type: None, - bandwidth: None, - cir: None, - mtu: None, - routing_mode: None, - interface_dia: None, vlan_id: Some(42), - user_tunnel_endpoint: None, - interface_cyoa: None, - status: None, - ip_net: None, - node_segment_idx: None, + ..Default::default() }, )), predicate::eq(vec![ @@ -190,6 +197,7 @@ mod tests { ip_net: None, interface_dia: None, node_segment_idx: None, + topology_names: None, }; let res = update_command.execute(&client); @@ -274,6 +282,7 @@ mod tests { user_tunnel_endpoint: None, status: None, ip_net: None, + topology_names: None, }; let res = update_command.execute(&client); @@ -329,9 +338,101 @@ mod tests { user_tunnel_endpoint: None, status: None, ip_net: None, + topology_names: None, }; let res = update_command.execute(&client); assert!(res.is_ok()); } + + /// Test that updating topologies appends seg_routing + topology PDAs and sets the flags + #[test] + fn test_commands_device_interface_update_topologies() { + let mut client = MockDoubleZeroClient::new(); + + let program_id = Pubkey::new_unique(); + client.expect_get_program_id().returning(move || program_id); + let payer = Pubkey::new_unique(); + client.expect_get_payer().returning(move || payer); + + let (globalstate_pubkey, bump_seed) = get_globalstate_pda(&program_id); + let globalstate = GlobalState { + account_type: AccountType::GlobalState, + bump_seed, + account_index: 0, + foundation_allowlist: vec![], + _device_allowlist: vec![], + _user_allowlist: vec![], + activator_authority_pk: Pubkey::new_unique(), + sentinel_authority_pk: Pubkey::new_unique(), + contributor_airdrop_lamports: 1_000_000_000, + user_airdrop_lamports: 40_000, + health_oracle_pk: Pubkey::new_unique(), + qa_allowlist: vec![], + feature_flags: FeatureFlag::OnChainAllocation.to_mask(), + feed_authority_pk: Pubkey::default(), + }; + client + .expect_get() + .with(predicate::eq(globalstate_pubkey)) + .returning(move |_| Ok(AccountData::GlobalState(globalstate.clone()))); + + let device_pubkey = Pubkey::new_unique(); + let device = make_test_device(); + let contributor_pk = device.contributor_pk; + + client + .expect_get() + .with(predicate::eq(device_pubkey)) + .returning(move |_| Ok(AccountData::Device(device.clone()))); + + let (seg_routing_pda, _, _) = get_resource_extension_pda( + &program_id, + doublezero_serviceability::resource::ResourceType::SegmentRoutingIds, + ); + let (topo_a, _) = doublezero_serviceability::pda::get_topology_pda(&program_id, "TOPO-A"); + let (topo_b, _) = doublezero_serviceability::pda::get_topology_pda(&program_id, "TOPO-B"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::UpdateDeviceInterface( + DeviceInterfaceUpdateArgs { + name: "Loopback256".to_string(), + topology_count: 2, + update_topologies: true, + ..Default::default() + }, + )), + predicate::eq(vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pk, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = UpdateDeviceInterfaceCommand { + pubkey: device_pubkey, + name: "Loopback256".to_string(), + loopback_type: None, + interface_cyoa: None, + interface_dia: None, + bandwidth: None, + cir: None, + mtu: None, + routing_mode: None, + vlan_id: None, + user_tunnel_endpoint: None, + status: None, + ip_net: None, + node_segment_idx: None, + topology_names: Some(vec!["TOPO-A".to_string(), "TOPO-B".to_string()]), + } + .execute(&client); + assert!(res.is_ok()); + } } From ff9bb237696327fc0ba221825de55b440dc4fdb4 Mon Sep 17 00:00:00 2001 From: Greg Mitchell Date: Fri, 8 May 2026 21:43:55 +0000 Subject: [PATCH 5/5] smartcontract: address PR review and update changelog - cli/device-interface-get: cache list_topology in a HashMap so we don't refetch per flex-algo segment - create/update: validate topology accounts by first-byte AccountType::Topology in addition to program-owner - update: allow contributor.owner to drive update_topologies, not just the foundation allowlist - update: size kept Vec by max(existing, desired) so we don't realloc when the desired set grows - tests: add test_update_topologies_allowed_for_contributor exercising the contributor-owner authorization branch - changelog: add Unreleased entries covering this PR's smartcontract, controller, and CLI changes --- CHANGELOG.md | 9 ++ smartcontract/cli/src/device/interface/get.rs | 25 ++--- .../src/processors/device/interface/create.rs | 6 ++ .../src/processors/device/interface/update.rs | 12 ++- .../interface_onchain_allocation_test.rs | 101 +++++++++++++++++- 5 files changed, 138 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d96331deb..d3f342640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to this project will be documented in this file. ### Changes +- Smartcontract + - Rename the `BackfillTopology` instruction to `AssignTopologyNodeSegments` across the program, CLI, and Rust SDK; the instruction discriminant (110) and on-disk semantics are unchanged ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) + - Extend `CreateDeviceInterface` with optional trailing topology PDA accounts (`topology_count: u8`); for Vpnv4 loopbacks under onchain allocation the processor allocates a `FlexAlgoNodeSegment` per topology atomically with interface creation, so newly-provisioned devices no longer need a separate `AssignTopologyNodeSegments` step. The CLI/SDK auto-discover existing topologies and pass them. Topology accounts are validated by program-owner and by first-byte `AccountType::Topology` ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) + - Extend `UpdateDeviceInterface` with `update_topologies: bool` + `topology_count: u8` to reconcile flex-algo node segments against a desired topology set on a Vpnv4 loopback (deallocate removed, allocate added, preserve unchanged); contributor owner or foundation allowlist can call it ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) + - `DeleteDeviceInterface` now deallocates flex-algo node segments alongside the loopback's base SR ID under onchain deallocation ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) +- Controller + - Stamp the default `UNICAST-DEFAULT` topology color on tunnels for users whose access pass has no tenant; previously the color was only resolved inside the tenant lookup branch, leaving tenantless users (the majority on mainnet/testnet) with no color community ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) +- CLI + - `doublezero device interface get` displays `flex_algo_node_segments` as `topology_name:sr_id` rows, falling back to a truncated pubkey when the topology cannot be looked up ([#3648](https://github.com/malbeclabs/doublezero/pull/3648)) - Smartcontract - Canonicalize the forward-compatible Device interface names: rename the new struct from `NewInterface` to `Interface`, the legacy enum from `Interface` to `InterfaceDeprecated`, the `Device::new_interfaces` field to `Device::interfaces`, and the legacy `Device::interfaces` field to `Device::deprecated_interfaces`. On-disk format and behavior are unchanged — Borsh is positional, so the regenerated device fixture is byte-identical. Mechanical rename across the serviceability program, processors, CLI, sentinel, client, controlplane admin, and the Rust SDK. The activator/ crate was already deleted in an earlier change so the rename does not touch it ([#3663](https://github.com/malbeclabs/doublezero/issues/3663)) - Stop reading `Device::deprecated_interfaces` outside of backward-compat tests. The `delete_device` processor and `Device::validate` now check the canonical `Device::interfaces` vec; the legacy slot is treated as wire-format-only state populated by the deserializer and projected by the serializer. CLI, processor, and Rust SDK construction sites use `..Default::default()` to avoid initializing the legacy field. The unused `UpdateDeviceCommand::deprecated_interfaces` SDK command field is removed. `Device::deprecated_interfaces` itself is retained for backward-compat fixture tests that verify legacy-slot decoding ([#3663](https://github.com/malbeclabs/doublezero/issues/3663)) diff --git a/smartcontract/cli/src/device/interface/get.rs b/smartcontract/cli/src/device/interface/get.rs index 23a481a27..aa524718e 100644 --- a/smartcontract/cli/src/device/interface/get.rs +++ b/smartcontract/cli/src/device/interface/get.rs @@ -51,25 +51,26 @@ impl GetDeviceInterfaceCliCommand { .find(|i| i.name.to_lowercase() == self.name.to_lowercase()) .ok_or_else(|| eyre::eyre!("Interface '{}' not found", self.name))?; - // Resolve flex-algo topology PDAs to names. If the lookup fails (e.g. the - // topology was deleted), fall back to a truncated pubkey. - let topology_name_for = |pk: &solana_sdk::pubkey::Pubkey| -> Option { - client - .list_topology(ListTopologyCommand) - .ok() - .and_then(|m| m.get(pk).map(|t| t.name.clone())) - }; + // Resolve flex-algo topology PDAs to names. Cache the topology map up + // front so we don't run `list_topology` once per segment; on lookup + // miss (e.g. the topology was deleted), fall back to a truncated pubkey. let flex_algo_node_segments = if interface.flex_algo_node_segments.is_empty() { String::new() } else { + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); interface .flex_algo_node_segments .iter() .map(|seg| { - let label = topology_name_for(&seg.topology).unwrap_or_else(|| { - let s = seg.topology.to_string(); - format!("{}…", &s[..8.min(s.len())]) - }); + let label = topology_map + .get(&seg.topology) + .map(|t| t.name.clone()) + .unwrap_or_else(|| { + let s = seg.topology.to_string(); + format!("{}…", &s[..8.min(s.len())]) + }); format!("{}:{}", label, seg.node_segment_idx) }) .collect::>() 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 c3df9ae86..ab42a95fc 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs @@ -237,6 +237,12 @@ pub fn process_create_device_interface( "Invalid Topology Account Owner" ); assert!(!topo_account.data_is_empty(), "Topology account is empty"); + let topo_type = AccountType::from(topo_account.try_borrow_data()?[0]); + assert_eq!( + topo_type, + AccountType::Topology, + "Invalid Topology Account Type" + ); let topo_segment_idx = allocate_id(segment_routing_ids_ext)?; flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topo_account.key, diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs index a566d041c..961073d30 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/update.rs @@ -258,7 +258,9 @@ pub fn process_update_device_interface( // for removed topologies have their SR ID deallocated; new topologies get a // freshly allocated SR ID. if value.update_topologies { - if !globalstate.foundation_allowlist.contains(payer_account.key) { + if contributor.owner != *payer_account.key + && !globalstate.foundation_allowlist.contains(payer_account.key) + { return Err(DoubleZeroError::NotAllowed.into()); } @@ -289,13 +291,19 @@ pub fn process_update_device_interface( "Invalid Topology Account Owner" ); assert!(!topo_account.data_is_empty(), "Topology account is empty"); + let topo_type = AccountType::from(topo_account.try_borrow_data()?[0]); + assert_eq!( + topo_type, + AccountType::Topology, + "Invalid Topology Account Type" + ); if !desired.insert(*topo_account.key) { return Err(DoubleZeroError::InvalidArgument.into()); } } let mut kept: Vec = - Vec::with_capacity(iface.flex_algo_node_segments.len()); + Vec::with_capacity(iface.flex_algo_node_segments.len().max(desired.len())); for entry in iface.flex_algo_node_segments.drain(..) { if desired.contains(&entry.topology) { kept.push(entry); diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs index f18f8764b..a2dd56b01 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_onchain_allocation_test.rs @@ -11,7 +11,7 @@ use doublezero_serviceability::{ instructions::*, pda::*, processors::{ - contributor::create::ContributorCreateArgs, + contributor::{create::ContributorCreateArgs, update::ContributorUpdateArgs}, device::{ activate::DeviceActivateArgs, create::DeviceCreateArgs, @@ -2281,3 +2281,102 @@ async fn test_update_topologies_rejects_duplicates() { _ => panic!("Unexpected error: {err:?}"), } } + +/// Test: update_topologies is authorized by the contributor's owner key, not just +/// the foundation allowlist. Reassigns contributor.owner to a new keypair (foundation +/// is required for that update), funds the new keypair, then signs UpdateDeviceInterface +/// with it. The new key is NOT in the foundation allowlist, so success here exercises +/// the contributor-owner authorization branch added by the review. +#[tokio::test] +async fn test_update_topologies_allowed_for_contributor() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + enable_onchain_allocation(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + let topo_a = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-a", + &payer, + ) + .await; + let topo_b = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topo-b", + &payer, + ) + .await; + + let (device_pubkey, contributor_pubkey, seg_routing_pda, _dtb_pda) = + setup_vpnv4_loopback_with_topologies( + &mut banks_client, + program_id, + globalstate_pubkey, + &payer, + &[topo_a], + ) + .await; + + // Hand the contributor over to a new keypair so contributor.owner is no longer + // on the foundation allowlist (only foundation can do this update). + let contributor_owner = Keypair::new(); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateContributor(ContributorUpdateArgs { + code: None, + owner: Some(contributor_owner.pubkey()), + ops_manager_pk: None, + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Fund the new owner so it can pay tx fees. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let transfer_ix = solana_sdk::system_instruction::transfer( + &payer.pubkey(), + &contributor_owner.pubkey(), + 1_000_000_000, + ); + let transfer_tx = solana_sdk::transaction::Transaction::new_signed_with_payer( + &[transfer_ix], + Some(&payer.pubkey()), + &[&payer], + recent_blockhash, + ); + banks_client.process_transaction(transfer_tx).await.unwrap(); + + // Sign update_topologies with the contributor.owner key (not foundation) — should succeed. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateDeviceInterface(update_topologies_args("Loopback0", 2)), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(seg_routing_pda, false), + AccountMeta::new_readonly(topo_a, false), + AccountMeta::new_readonly(topo_b, false), + ], + &contributor_owner, + ) + .await; + + let device = get_device(&mut banks_client, device_pubkey).await.unwrap(); + let mut expected = vec![topo_a, topo_b]; + expected.sort(); + assert_eq!(topology_set(&device.interfaces[0]), expected); +}