diff --git a/.gitignore b/.gitignore index b8a8b47452..e905c41978 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ dev/.deploy/ # Ignore the directory used for checking out the monitor tool in CI /doublezero_monitor/ .claude/settings.local.json +.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b5de2fb0a6..65e11a6b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,19 @@ All notable changes to this project will be documented in this file. - Client - Add `doublezero_connection_info` Prometheus metric exposing connection metadata (user_type, network, current_device, metro, tunnel_name, tunnel_src, tunnel_dst) ([#3201](https://github.com/malbeclabs/doublezero/pull/3201)) - Add `doublezero_connection_rtt_nanoseconds` and `doublezero_connection_loss_percentage` Prometheus metrics reporting RTT and packet loss to the current connected device +- Smartcontract + - Add `TopologyInfo` onchain account for IS-IS flex-algo link classification: auto-assigned TE admin-group bit (1–62), derived flex-algo number (128 + bit), and constraint type (`include-any`/`include-all`); capped at 62 topologies via `AdminGroupBits` resource extension + - Add `link_topologies: Vec` (capped at 8) and `link_flags: u8` (bit 0 = unicast-drained) to the `Link` account + - Add `include_topologies` to the `Tenant` account for topology-filtered routing opt-in + - Enforce UNICAST-DEFAULT topology existence as a precondition for link activation +- CLI + - Add `doublezero link topology` subcommands: `create`, `delete`, `clear`, `list`, `backfill` + - Add `--link-topology ` and `--unicast-drained ` flags to `doublezero link update` + - Add `--topology ` filter to `doublezero link list` (`default` = untagged links) + - Add `--include-topologies ` flag to `doublezero tenant update` + - Add `doublezero-admin migrate flex-algo [--dry-run]` to tag existing links with UNICAST-DEFAULT and backfill node segments +- SDK + - Update Go, Python, and TypeScript SDKs with `TopologyInfo` deserialization and new `link_topologies`, `link_flags`, and `include_topologies` fields ## [v0.15.0](https://github.com/malbeclabs/doublezero/compare/client/v0.14.0...client/v0.15.0) - 2026-03-27 diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index fee82e46be..1444bdc04c 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -271,6 +271,9 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let tunnel_cloned = tunnel.clone(); @@ -397,6 +400,9 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let link_cloned = link.clone(); @@ -457,6 +463,9 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let tunnel_clone = tunnel.clone(); @@ -544,6 +553,9 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; // SDK command fetches the link internally @@ -623,6 +635,9 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; // SDK command fetches the link internally diff --git a/activator/src/processor.rs b/activator/src/processor.rs index a43b736104..b9c106f933 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -764,6 +764,9 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let mut existing_links: HashMap = HashMap::new(); @@ -798,6 +801,9 @@ mod tests { side_z_iface_name: "Ethernet3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let new_link_cloned = new_link.clone(); diff --git a/client/doublezero/src/cli/link.rs b/client/doublezero/src/cli/link.rs index 86b41ac6b0..826547cbb0 100644 --- a/client/doublezero/src/cli/link.rs +++ b/client/doublezero/src/cli/link.rs @@ -1,8 +1,15 @@ use clap::{Args, Subcommand}; -use doublezero_cli::link::{ - accept::AcceptLinkCliCommand, delete::*, dzx_create::CreateDZXLinkCliCommand, get::*, - latency::LinkLatencyCliCommand, list::*, sethealth::SetLinkHealthCliCommand, update::*, - wan_create::*, +use doublezero_cli::{ + link::{ + accept::AcceptLinkCliCommand, delete::*, dzx_create::CreateDZXLinkCliCommand, get::*, + latency::LinkLatencyCliCommand, list::*, sethealth::SetLinkHealthCliCommand, update::*, + wan_create::*, + }, + topology::{ + backfill::BackfillTopologyCliCommand, clear::ClearTopologyCliCommand, + create::CreateTopologyCliCommand, delete::DeleteTopologyCliCommand, + list::ListTopologyCliCommand, + }, }; #[derive(Args, Debug)] @@ -53,4 +60,27 @@ pub enum LinkCommands { // Hidden because this is an internal/operational command not intended for general CLI users. #[clap(hide = true)] SetHealth(SetLinkHealthCliCommand), + /// Manage link topologies + #[clap()] + Topology(TopologyLinkCommand), +} + +#[derive(Args, Debug)] +pub struct TopologyLinkCommand { + #[command(subcommand)] + pub command: TopologyCommands, +} + +#[derive(Debug, Subcommand)] +pub enum TopologyCommands { + /// Create a new topology + Create(CreateTopologyCliCommand), + /// Delete a topology + Delete(DeleteTopologyCliCommand), + /// Clear a topology from links + Clear(ClearTopologyCliCommand), + /// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks + Backfill(BackfillTopologyCliCommand), + /// List all topologies + List(ListTopologyCliCommand), } diff --git a/client/doublezero/src/command/connect.rs b/client/doublezero/src/command/connect.rs index df78c19513..b9ff9ee347 100644 --- a/client/doublezero/src/command/connect.rs +++ b/client/doublezero/src/command/connect.rs @@ -1072,6 +1072,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mut tenants = HashMap::new(); @@ -1397,6 +1398,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; tenants.insert(pk, tenant.clone()); (pk, tenant) diff --git a/client/doublezero/src/dzd_latency.rs b/client/doublezero/src/dzd_latency.rs index 3b4e09fcf5..58a56c947d 100644 --- a/client/doublezero/src/dzd_latency.rs +++ b/client/doublezero/src/dzd_latency.rs @@ -274,6 +274,7 @@ mod tests { ip_net: NetworkV4::new(ip, 32).unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }) }) .collect(); diff --git a/client/doublezero/src/main.rs b/client/doublezero/src/main.rs index 17f5dbc8ed..dbc4464886 100644 --- a/client/doublezero/src/main.rs +++ b/client/doublezero/src/main.rs @@ -17,7 +17,7 @@ use crate::cli::{ AirdropCommands, AuthorityCommands, FeatureFlagsCommands, FoundationAllowlistCommands, GlobalConfigCommands, QaAllowlistCommands, }, - link::LinkCommands, + link::{LinkCommands, TopologyCommands}, location::LocationCommands, user::UserCommands, }; @@ -232,6 +232,13 @@ async fn main() -> eyre::Result<()> { LinkCommands::Latency(args) => args.execute(&client, &mut handle), LinkCommands::Delete(args) => args.execute(&client, &mut handle), LinkCommands::SetHealth(args) => args.execute(&client, &mut handle), + LinkCommands::Topology(args) => match args.command { + TopologyCommands::Create(args) => args.execute(&client, &mut handle), + TopologyCommands::Delete(args) => args.execute(&client, &mut handle), + TopologyCommands::Clear(args) => args.execute(&client, &mut handle), + TopologyCommands::Backfill(args) => args.execute(&client, &mut handle), + TopologyCommands::List(args) => args.execute(&client, &mut handle), + }, }, Command::AccessPass(command) => match command.command { cli::accesspass::AccessPassCommands::Set(args) => args.execute(&client, &mut handle), diff --git a/controlplane/controller/internal/controller/models.go b/controlplane/controller/internal/controller/models.go index 2c8a0add5b..dcf970c675 100644 --- a/controlplane/controller/internal/controller/models.go +++ b/controlplane/controller/internal/controller/models.go @@ -49,7 +49,7 @@ type Interface struct { // toInterface validates onchain data for a serviceability interface and converts it to a controller interface. func toInterface(iface serviceability.Interface) (Interface, error) { - if iface == (serviceability.Interface{}) { + if iface.IpNet == ([5]byte{}) && iface.Name == "" { return Interface{}, errors.New("serviceability interface cannot be nil") } diff --git a/controlplane/controller/internal/controller/server.go b/controlplane/controller/internal/controller/server.go index 07298c7877..bc4eabcdff 100644 --- a/controlplane/controller/internal/controller/server.go +++ b/controlplane/controller/internal/controller/server.go @@ -348,22 +348,22 @@ func (c *Controller) updateStateCache(ctx context.Context) error { cache.Ipv4BgpPeers = append(cache.Ipv4BgpPeers, candidateIpv4BgpPeer) // determine if interface is in an onchain link and assign metrics - findLink := func(intf Interface) serviceability.Link { - for _, link := range links { + findLink := func(intf Interface) *serviceability.Link { + for i, link := range links { if d.PubKey == base58.Encode(link.SideAPubKey[:]) && intf.Name == link.SideAIfaceName { - return link + return &links[i] } if d.PubKey == base58.Encode(link.SideZPubKey[:]) && intf.Name == link.SideZIfaceName { - return link + return &links[i] } } - return serviceability.Link{} + return nil } for i, iface := range d.Interfaces { link := findLink(iface) - if link == (serviceability.Link{}) || (link.Status != serviceability.LinkStatusActivated && link.Status != serviceability.LinkStatusSoftDrained && link.Status != serviceability.LinkStatusHardDrained) { + if link == nil || (link.Status != serviceability.LinkStatusActivated && link.Status != serviceability.LinkStatusSoftDrained && link.Status != serviceability.LinkStatusHardDrained) { d.Interfaces[i].IsLink = false d.Interfaces[i].Metric = 0 d.Interfaces[i].LinkStatus = serviceability.LinkStatusPending diff --git a/controlplane/doublezero-admin/src/cli/command.rs b/controlplane/doublezero-admin/src/cli/command.rs index 8d3826ed50..aa84e6f51d 100644 --- a/controlplane/doublezero-admin/src/cli/command.rs +++ b/controlplane/doublezero-admin/src/cli/command.rs @@ -2,8 +2,8 @@ use super::{multicast::MulticastCliCommand, sentinel::SentinelCliCommand}; use crate::cli::{ accesspass::AccessPassCliCommand, config::ConfigCliCommand, contributor::ContributorCliCommand, device::DeviceCliCommand, exchange::ExchangeCliCommand, globalconfig::GlobalConfigCliCommand, - link::LinkCliCommand, location::LocationCliCommand, permission::PermissionCliCommand, - tenant::TenantCliCommand, user::UserCliCommand, + link::LinkCliCommand, location::LocationCliCommand, migrate::MigrateCliCommand, + permission::PermissionCliCommand, tenant::TenantCliCommand, user::UserCliCommand, }; use clap::{Args, Subcommand}; use clap_complete::Shell; @@ -69,6 +69,9 @@ pub enum Command { /// Sentinel admin commands #[command()] Sentinel(SentinelCliCommand), + /// Backfill link topologies and report Vpnv4 loopback gaps (RFC-18 migration) + #[command()] + Migrate(MigrateCliCommand), /// Export all data to files #[command()] Export(ExportCliCommand), diff --git a/controlplane/doublezero-admin/src/cli/migrate.rs b/controlplane/doublezero-admin/src/cli/migrate.rs new file mode 100644 index 0000000000..6eebb850d4 --- /dev/null +++ b/controlplane/doublezero-admin/src/cli/migrate.rs @@ -0,0 +1,183 @@ +use clap::{Args, Subcommand}; +use doublezero_cli::doublezerocommand::CliCommand; +use doublezero_sdk::commands::{ + device::list::ListDeviceCommand, + link::{list::ListLinkCommand, update::UpdateLinkCommand}, + topology::{backfill::BackfillTopologyCommand, list::ListTopologyCommand}, +}; +use doublezero_serviceability::{pda::get_topology_pda, state::interface::LoopbackType}; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct MigrateCliCommand { + #[command(subcommand)] + pub command: MigrateCommands, +} + +#[derive(Debug, Subcommand)] +pub enum MigrateCommands { + /// Backfill link topologies and Vpnv4 loopback FlexAlgoNodeSegments (RFC-18 migration) + FlexAlgo(FlexAlgoMigrateCliCommand), +} + +#[derive(Args, Debug)] +pub struct FlexAlgoMigrateCliCommand { + /// Print what would be changed without submitting transactions + #[arg(long, default_value_t = false)] + pub dry_run: bool, +} + +impl FlexAlgoMigrateCliCommand { + pub fn execute(&self, client: &C, out: &mut W) -> eyre::Result<()> { + let program_id = client.get_program_id(); + + // Verify UNICAST-DEFAULT topology PDA exists on chain. + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + client + .get_account(unicast_default_pda) + .map_err(|_| eyre::eyre!("UNICAST-DEFAULT topology PDA {unicast_default_pda} not found on chain — cannot proceed"))?; + + // ── Part 1: link topology backfill ─────────────────────────────────────── + + let links = client.list_link(ListLinkCommand)?; + let mut links_tagged = 0u32; + let mut links_needing_tag = 0u32; + let mut links_skipped = 0u32; + + let mut link_entries: Vec<(Pubkey, _)> = links.into_iter().collect(); + link_entries.sort_by_key(|(pk, _)| pk.to_string()); + + for (pubkey, link) in &link_entries { + if link.link_topologies.is_empty() { + links_needing_tag += 1; + writeln!( + out, + " [link] {pubkey} ({}) — would tag UNICAST-DEFAULT", + link.code + )?; + if !self.dry_run { + let result = client.update_link(UpdateLinkCommand { + pubkey: *pubkey, + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + link_topologies: Some(vec![unicast_default_pda]), + unicast_drained: None, + }); + match result { + Ok(sig) => { + links_tagged += 1; + writeln!(out, " tagged: {sig}")?; + } + Err(e) => { + writeln!(out, " WARNING: failed to tag {pubkey}: {e}")?; + } + } + } + } else { + links_skipped += 1; + } + } + + // ── Part 2: Vpnv4 loopback FlexAlgoNodeSegment backfill ───────────────── + + let topologies = client.list_topology(ListTopologyCommand)?; + let mut topology_entries: Vec<(Pubkey, _)> = topologies.into_iter().collect(); + topology_entries.sort_by_key(|(pk, _)| pk.to_string()); + + let devices = client.list_device(ListDeviceCommand)?; + let mut device_entries: Vec<(Pubkey, _)> = devices.into_iter().collect(); + device_entries.sort_by_key(|(pk, _)| pk.to_string()); + + // For each topology, find devices that have Vpnv4 loopbacks missing that + // topology's segment — then backfill in a single transaction per topology. + let mut topologies_backfilled = 0u32; + let mut topologies_skipped = 0u32; + + for (topology_pubkey, topology) in &topology_entries { + // Collect devices that have at least one Vpnv4 loopback missing this topology + let mut devices_needing_backfill: Vec = Vec::new(); + + for (device_pubkey, device) in &device_entries { + let needs_backfill = device.interfaces.iter().any(|iface| { + let current = iface.into_current_version(); + current.loopback_type == LoopbackType::Vpnv4 + && !current + .flex_algo_node_segments + .iter() + .any(|s| s.topology == *topology_pubkey) + }); + if needs_backfill { + devices_needing_backfill.push(*device_pubkey); + } + } + + if devices_needing_backfill.is_empty() { + topologies_skipped += 1; + continue; + } + + topologies_backfilled += 1; + writeln!( + out, + " [topology] {} ({}) — {} device(s) need backfill", + topology.name, + topology_pubkey, + devices_needing_backfill.len() + )?; + + if !self.dry_run { + let result = client.backfill_topology(BackfillTopologyCommand { + name: topology.name.clone(), + device_pubkeys: devices_needing_backfill, + }); + match result { + Ok(sig) => { + writeln!(out, " backfilled: {sig}")?; + } + Err(e) => { + writeln!( + out, + " WARNING: failed to backfill topology {}: {e}", + topology.name + )?; + } + } + } + } + + // ── Summary ────────────────────────────────────────────────────────────── + + let dry_run_suffix = if self.dry_run { + " [DRY RUN — no changes made]" + } else { + "" + }; + let tagged_summary = if self.dry_run { + format!("{links_needing_tag} link(s) would be tagged") + } else { + format!("{links_tagged} link(s) tagged") + }; + let loopback_summary = if self.dry_run { + format!("{topologies_backfilled} topology(s) would be backfilled") + } else { + format!("{topologies_backfilled} topology(s) backfilled") + }; + writeln!( + out, + "\nMigration complete: {tagged_summary}, {links_skipped} link(s) skipped; {loopback_summary}, {topologies_skipped} topology(s) already complete{dry_run_suffix}" + )?; + + Ok(()) + } +} diff --git a/controlplane/doublezero-admin/src/cli/mod.rs b/controlplane/doublezero-admin/src/cli/mod.rs index 3985558bed..1fab197adc 100644 --- a/controlplane/doublezero-admin/src/cli/mod.rs +++ b/controlplane/doublezero-admin/src/cli/mod.rs @@ -7,6 +7,7 @@ pub mod exchange; pub mod globalconfig; pub mod link; pub mod location; +pub mod migrate; pub mod multicast; pub mod multicastgroup; pub mod permission; diff --git a/controlplane/doublezero-admin/src/main.rs b/controlplane/doublezero-admin/src/main.rs index 73f4986141..253ee4b0ce 100644 --- a/controlplane/doublezero-admin/src/main.rs +++ b/controlplane/doublezero-admin/src/main.rs @@ -256,6 +256,9 @@ async fn main() -> eyre::Result<()> { } }, + Command::Migrate(args) => match args.command { + cli::migrate::MigrateCommands::FlexAlgo(cmd) => cmd.execute(&client, &mut handle), + }, Command::Export(args) => args.execute(&client, &mut handle), Command::Keygen(args) => args.execute(&client, &mut handle), Command::Log(args) => args.execute(&dzclient, &mut handle), diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go index 8fc0f192db..1552d3c978 100644 --- a/e2e/compatibility_test.go +++ b/e2e/compatibility_test.go @@ -121,34 +121,42 @@ var knownIncompatibilities = map[string]knownIncompat{ "write/device_drain": {ranges: before("0.8.1")}, "write/device_drain_2": {ranges: before("0.8.1")}, - // device interface / link commands: --mtu requirement changed from 2048 to 9000. - // Versions before 0.12.0 didn't have these commands; versions 0.12.0–0.15.x send - // the old MTU value which the current program rejects. + // device interface / link commands: --mtu requirement changed from 2048 to 9000 in v0.16.0, + // and RFC-18 added flex_algo_node_segments to InterfaceV2 which old CLIs cannot deserialize. + // Versions 0.12.0–0.15.x send the old MTU value which the current program rejects. + // Versions 0.10.0–0.11.0 can CREATE interfaces (the CLI sends the instruction without reading + // back), but cannot UPDATE or otherwise operate on them because reading the new InterfaceV2 + // format (with trailing flex_algo_node_segments bytes) fails deserialization. + // + // Testnet v0.16.0 was built before the RFC-18 InterfaceV2 change and cannot deserialize the + // new account format. However it CAN create interfaces (the create instruction doesn't read + // back), so device_interface_create does not need a testnet override. Operations that read + // interface accounts (set_unlinked, link_create, etc.) do need the override. "write/device_interface_create": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, "write/device_interface_create_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, "write/device_interface_create_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, "write/device_interface_create_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_set_unlinked_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_create_wan": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_create_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_accept_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_update": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_set_health": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_set_health_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_get": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_wait_activated": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_wait_activated_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_drain": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_drain_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/link_delete_dzx": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete_2": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete_3": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, - "write/device_interface_delete_4": {ranges: []versionRange{{from: "0.12.0", before: "0.16.0"}}}, + "write/device_interface_set_unlinked": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_2": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_3": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_set_unlinked_4": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_create_wan": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_create_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_accept_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_update": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_set_health": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_set_health_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_get": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_wait_activated": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_wait_activated_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_drain": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_drain_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_delete": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/link_delete_dzx": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_2": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_3": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, + "write/device_interface_delete_4": {ranges: []versionRange{{from: "0.10.0", before: "0.16.0"}}, envOverride: map[string][]versionRange{"testnet": {{from: "0.10.0", before: "0.17.0"}}}}, } // ============================================================================= @@ -781,6 +789,13 @@ func createAndStartVersionDevnet( _, _ = dn.Manager.Exec(t.Context(), []string{"bash", "-c", "doublezero global-config set --multicast-publisher-block 148.51.120.0/21"}) + // Ensure the unicast-default topology exists. The link activate processor (RFC-18) + // requires this topology account to be present. Ignore errors — if the topology + // already exists in the cloned state (e.g. testnet after RFC-18 deployment), this + // is a no-op. Use the current CLI since old CLIs don't have topology commands. + _, _ = dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero link topology create --name unicast-default --constraint include-any"}) + // Start the activator — it needs the PDAs to exist. // Skip the controller (not exercised in compat tests, saves memory). _, err = dn.Activator.StartIfNotRunning(t.Context()) diff --git a/e2e/docker/manager/Dockerfile b/e2e/docker/manager/Dockerfile index a304ec5d9f..89c89cf5e6 100644 --- a/e2e/docker/manager/Dockerfile +++ b/e2e/docker/manager/Dockerfile @@ -13,6 +13,7 @@ COPY --from=base /doublezero/bin/doublezero_serviceability.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_telemetry.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero_geolocation.so /doublezero/bin/. COPY --from=base /doublezero/bin/doublezero-geolocation /doublezero/bin/. +COPY --from=base /doublezero/bin/doublezero-admin /doublezero/bin/. COPY --from=base /usr/local/bin/solana /usr/local/bin/. COPY --from=base /usr/local/bin/solana-keygen /usr/local/bin/. diff --git a/e2e/internal/devnet/smartcontract_init.go b/e2e/internal/devnet/smartcontract_init.go index 90145ea345..6274e8c750 100644 --- a/e2e/internal/devnet/smartcontract_init.go +++ b/e2e/internal/devnet/smartcontract_init.go @@ -84,6 +84,10 @@ func (dn *Devnet) InitSmartContract(ctx context.Context) error { doublezero global-config authority set --activator-authority me --sentinel-authority me + # Create the unicast-default topology required by the link activate processor (RFC-18). + # Must run after global-config set, which initializes the admin_group_bits resource extension. + doublezero link topology create --name unicast-default --constraint include-any + doublezero location create --code lax --name "Los Angeles" --country US --lat 34.049641274076464 --lng -118.25939642499903 doublezero location create --code ewr --name "New York" --country US --lat 40.780297071772125 --lng -74.07203003496925 doublezero location create --code lhr --name "London" --country UK --lat 51.513999803939384 --lng -0.12014764843092213 diff --git a/sdk/serviceability/python/serviceability/state.py b/sdk/serviceability/python/serviceability/state.py index 1a61b961d9..ac79442aa8 100644 --- a/sdk/serviceability/python/serviceability/state.py +++ b/sdk/serviceability/python/serviceability/state.py @@ -42,6 +42,7 @@ class AccountTypeEnum(IntEnum): ACCESS_PASS = 11 TENANT = 13 PERMISSION = 15 + TOPOLOGY = 16 # --------------------------------------------------------------------------- @@ -400,7 +401,13 @@ def __str__(self) -> str: # Account dataclasses # --------------------------------------------------------------------------- -CURRENT_INTERFACE_VERSION = 2 +CURRENT_INTERFACE_VERSION = 3 + + +@dataclass +class FlexAlgoNodeSegment: + topology: Pubkey = Pubkey.default() + node_segment_idx: int = 0 @dataclass @@ -420,9 +427,10 @@ class Interface: ip_net: bytes = b"\x00" * 5 node_segment_idx: int = 0 user_tunnel_endpoint: bool = False + flex_algo_node_segments: list[FlexAlgoNodeSegment] = field(default_factory=list) @classmethod - def from_reader(cls, r: IncrementalReader) -> Interface: + def from_reader(cls, r: DefensiveReader) -> Interface: iface = cls() iface.version = r.read_u8() if iface.version > CURRENT_INTERFACE_VERSION - 1: @@ -436,7 +444,7 @@ def from_reader(cls, r: IncrementalReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() - elif iface.version == 1: # V2 + elif iface.version in (1, 2): # V2 or V3 iface.status = InterfaceStatus(r.read_u8()) iface.name = r.read_string() iface.interface_type = InterfaceType(r.read_u8()) @@ -451,6 +459,13 @@ def from_reader(cls, r: IncrementalReader) -> Interface: iface.ip_net = r.read_network_v4() iface.node_segment_idx = r.read_u16() iface.user_tunnel_endpoint = r.read_bool() + if iface.version in (1, 2): # flex_algo_node_segments present in V2 and V3 + count = r.read_u32() + for _ in range(count): + seg = FlexAlgoNodeSegment() + seg.topology = _read_pubkey(r) + seg.node_segment_idx = r.read_u16() + iface.flex_algo_node_segments.append(seg) return iface @@ -679,6 +694,8 @@ class Link: delay_override_ns: int = 0 link_health: LinkHealth = LinkHealth.UNKNOWN link_desired_status: LinkDesiredStatus = LinkDesiredStatus.PENDING + link_topologies: list[Pubkey] = field(default_factory=list) + link_flags: int = 0 @classmethod def from_bytes(cls, data: bytes) -> Link: @@ -705,6 +722,8 @@ def from_bytes(cls, data: bytes) -> Link: lk.delay_override_ns = r.read_u64() lk.link_health = LinkHealth(r.read_u8()) lk.link_desired_status = LinkDesiredStatus(r.read_u8()) + lk.link_topologies = _read_pubkey_vec(r) + lk.link_flags = r.read_u8() return lk @@ -868,6 +887,7 @@ class Tenant: billing_discriminant: int = 0 billing_rate: int = 0 billing_last_deduction_dz_epoch: int = 0 + include_topologies: list[Pubkey] = field(default_factory=list) @classmethod def from_bytes(cls, data: bytes) -> Tenant: @@ -887,6 +907,7 @@ def from_bytes(cls, data: bytes) -> Tenant: t.billing_discriminant = r.read_u8() t.billing_rate = r.read_u64() t.billing_last_deduction_dz_epoch = r.read_u64() + t.include_topologies = _read_pubkey_vec(r) return t @@ -995,3 +1016,42 @@ def from_bytes(cls, data: bytes) -> Permission: hi = r.read_u64() p.permissions = lo | (hi << 64) return p + + +# --------------------------------------------------------------------------- +# TopologyInfo +# --------------------------------------------------------------------------- + + +class TopologyConstraint(IntEnum): + INCLUDE_ANY = 0 + EXCLUDE = 1 + + def __str__(self) -> str: + _names = {0: "include-any", 1: "exclude"} + return _names.get(self.value, "unknown") + + +@dataclass +class TopologyInfo: + account_type: int = 0 + owner: Pubkey = Pubkey.default() + bump_seed: int = 0 + name: str = "" + admin_group_bit: int = 0 + flex_algo_number: int = 0 + constraint: TopologyConstraint = TopologyConstraint.INCLUDE_ANY + pub_key: Pubkey = Pubkey.default() # set from account address after deserialization + + @classmethod + def from_bytes(cls, data: bytes) -> TopologyInfo: + r = DefensiveReader(data) + t = cls() + t.account_type = r.read_u8() + t.owner = _read_pubkey(r) + t.bump_seed = r.read_u8() + t.name = r.read_string() + t.admin_group_bit = r.read_u8() + t.flex_algo_number = r.read_u8() + t.constraint = TopologyConstraint(r.read_u8()) + return t diff --git a/sdk/serviceability/testdata/fixtures/device.bin b/sdk/serviceability/testdata/fixtures/device.bin index 8554f47fb1..5e322d10ae 100644 Binary files a/sdk/serviceability/testdata/fixtures/device.bin and b/sdk/serviceability/testdata/fixtures/device.bin differ diff --git a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs index 78b1293eda..38e9ef0628 100644 --- a/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs +++ b/sdk/serviceability/testdata/fixtures/generate-fixtures/src/main.rs @@ -87,6 +87,7 @@ fn main() { generate_access_pass(&fixtures_dir); generate_access_pass_validator(&fixtures_dir); generate_tenant(&fixtures_dir); + generate_topology(&fixtures_dir); generate_resource_extension_id(&fixtures_dir); generate_resource_extension_ip(&fixtures_dir); @@ -323,6 +324,7 @@ fn generate_device(dir: &Path) { ip_net: "172.16.0.1/30".parse().unwrap(), node_segment_idx: 200, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }), ], reference_count: 12, @@ -410,6 +412,7 @@ fn generate_link(dir: &Path) { let side_a_pk = pubkey_from_byte(0x51); let side_z_pk = pubkey_from_byte(0x52); let contributor_pk = pubkey_from_byte(0x53); + let topology_pk = pubkey_from_byte(0x54); let val = Link { account_type: AccountType::Link, @@ -433,6 +436,8 @@ fn generate_link(dir: &Path) { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![topology_pk], + link_flags: doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED, }; let data = borsh::to_vec(&val).unwrap(); @@ -462,6 +467,9 @@ fn generate_link(dir: &Path) { FieldValue { name: "DelayOverrideNs".into(), value: "0".into(), typ: "u64".into() }, FieldValue { name: "LinkHealth".into(), value: "2".into(), typ: "u8".into() }, FieldValue { name: "DesiredStatus".into(), value: "1".into(), typ: "u8".into() }, + FieldValue { name: "LinkTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "LinkTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, + FieldValue { name: "LinkFlags".into(), value: "1".into(), typ: "u8".into() }, ], }; @@ -745,6 +753,7 @@ fn generate_tenant(dir: &Path) { let owner = pubkey_from_byte(0xD0); let admin_pk = pubkey_from_byte(0xD1); let token_account = pubkey_from_byte(0xD2); + let topology_pk = pubkey_from_byte(0xD3); let val = Tenant { account_type: AccountType::Tenant, @@ -759,6 +768,7 @@ fn generate_tenant(dir: &Path) { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![topology_pk], }; let data = borsh::to_vec(&val).unwrap(); @@ -782,12 +792,46 @@ fn generate_tenant(dir: &Path) { FieldValue { name: "BillingDiscriminant".into(), value: "0".into(), typ: "u8".into() }, FieldValue { name: "BillingRate".into(), value: "0".into(), typ: "u64".into() }, FieldValue { name: "BillingLastDeductionDzEpoch".into(), value: "0".into(), typ: "u64".into() }, + FieldValue { name: "IncludeTopologiesLen".into(), value: "1".into(), typ: "u32".into() }, + FieldValue { name: "IncludeTopologies0".into(), value: pubkey_bs58(&topology_pk), typ: "pubkey".into() }, ], }; write_fixture(dir, "tenant", &data, &meta); } +fn generate_topology(dir: &Path) { + let owner = pubkey_from_byte(0xE0); + + let val = doublezero_serviceability::state::topology::TopologyInfo { + account_type: AccountType::Topology, + owner, + bump_seed: 250, + name: "unicast-default".into(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }; + + let data = borsh::to_vec(&val).unwrap(); + + let meta = FixtureMeta { + name: "TopologyInfo".into(), + account_type: 16, + fields: vec![ + FieldValue { name: "AccountType".into(), value: "16".into(), typ: "u8".into() }, + FieldValue { name: "Owner".into(), value: pubkey_bs58(&owner), typ: "pubkey".into() }, + FieldValue { name: "BumpSeed".into(), value: "250".into(), typ: "u8".into() }, + FieldValue { name: "Name".into(), value: "unicast-default".into(), typ: "string".into() }, + FieldValue { name: "AdminGroupBit".into(), value: "0".into(), typ: "u8".into() }, + FieldValue { name: "FlexAlgoNumber".into(), value: "128".into(), typ: "u8".into() }, + FieldValue { name: "Constraint".into(), value: "0".into(), typ: "u8".into() }, + ], + }; + + write_fixture(dir, "topology_info", &data, &meta); +} + /// ResourceExtension uses a fixed binary layout with bitmap at offset 88, /// so we manually construct the bytes rather than using borsh::to_vec. const RESOURCE_EXTENSION_BITMAP_OFFSET: usize = 88; diff --git a/sdk/serviceability/testdata/fixtures/link.bin b/sdk/serviceability/testdata/fixtures/link.bin index e988d5d648..3390047b45 100644 Binary files a/sdk/serviceability/testdata/fixtures/link.bin and b/sdk/serviceability/testdata/fixtures/link.bin differ diff --git a/sdk/serviceability/testdata/fixtures/link.json b/sdk/serviceability/testdata/fixtures/link.json index b1f786b209..021c5f9433 100644 --- a/sdk/serviceability/testdata/fixtures/link.json +++ b/sdk/serviceability/testdata/fixtures/link.json @@ -106,6 +106,21 @@ "name": "DesiredStatus", "value": "1", "typ": "u8" + }, + { + "name": "LinkTopologiesLen", + "value": "1", + "typ": "u32" + }, + { + "name": "LinkTopologies0", + "value": "6euFJbx65EayK76qjPNWCqcCjpTkJoZA3rubHP4bYaXy", + "typ": "pubkey" + }, + { + "name": "LinkFlags", + "value": "1", + "typ": "u8" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/tenant.bin b/sdk/serviceability/testdata/fixtures/tenant.bin index bfc41f6d8c..0748273f3c 100644 Binary files a/sdk/serviceability/testdata/fixtures/tenant.bin and b/sdk/serviceability/testdata/fixtures/tenant.bin differ diff --git a/sdk/serviceability/testdata/fixtures/tenant.json b/sdk/serviceability/testdata/fixtures/tenant.json index 83ea1f76b8..a8fdcae48e 100644 --- a/sdk/serviceability/testdata/fixtures/tenant.json +++ b/sdk/serviceability/testdata/fixtures/tenant.json @@ -76,6 +76,16 @@ "name": "BillingLastDeductionDzEpoch", "value": "0", "typ": "u64" + }, + { + "name": "IncludeTopologiesLen", + "value": "1", + "typ": "u32" + }, + { + "name": "IncludeTopologies0", + "value": "FCf2QW6oaRTeBxNaLUCCuXGEc4DdyTVJD25ND9Tz5kVm", + "typ": "pubkey" } ] } \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/topology_info.bin b/sdk/serviceability/testdata/fixtures/topology_info.bin new file mode 100644 index 0000000000..b787eb7fb1 Binary files /dev/null and b/sdk/serviceability/testdata/fixtures/topology_info.bin differ diff --git a/sdk/serviceability/testdata/fixtures/topology_info.json b/sdk/serviceability/testdata/fixtures/topology_info.json new file mode 100644 index 0000000000..71f1216614 --- /dev/null +++ b/sdk/serviceability/testdata/fixtures/topology_info.json @@ -0,0 +1,41 @@ +{ + "name": "TopologyInfo", + "account_type": 16, + "fields": [ + { + "name": "AccountType", + "value": "16", + "typ": "u8" + }, + { + "name": "Owner", + "value": "G5QKowXuCHtvVwwEx2f1YZxCyBZf9op5oJ5ukgVvTD6F", + "typ": "pubkey" + }, + { + "name": "BumpSeed", + "value": "250", + "typ": "u8" + }, + { + "name": "Name", + "value": "unicast-default", + "typ": "string" + }, + { + "name": "AdminGroupBit", + "value": "0", + "typ": "u8" + }, + { + "name": "FlexAlgoNumber", + "value": "128", + "typ": "u8" + }, + { + "name": "Constraint", + "value": "0", + "typ": "u8" + } + ] +} \ No newline at end of file diff --git a/sdk/serviceability/testdata/fixtures/user.json b/sdk/serviceability/testdata/fixtures/user.json index 426aa11c38..2093b5a039 100644 --- a/sdk/serviceability/testdata/fixtures/user.json +++ b/sdk/serviceability/testdata/fixtures/user.json @@ -118,4 +118,4 @@ "typ": "u64" } ] -} +} \ No newline at end of file diff --git a/sdk/serviceability/typescript/serviceability/rpc.ts b/sdk/serviceability/typescript/serviceability/rpc.ts index ab2f0ff835..50af3cb379 100644 --- a/sdk/serviceability/typescript/serviceability/rpc.ts +++ b/sdk/serviceability/typescript/serviceability/rpc.ts @@ -1,6 +1,7 @@ import { Connection, type ConnectionConfig } from "@solana/web3.js"; const DEFAULT_MAX_RETRIES = 5; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; /** * Creates a Solana RPC Connection with retry on 429 Too Many Requests. @@ -8,18 +9,29 @@ const DEFAULT_MAX_RETRIES = 5; * The built-in @solana/web3.js retry uses short backoffs (500ms-4s) that * may not be sufficient for rate-limited public RPC endpoints. This wrapper * provides longer backoff intervals (2s, 4s, 6s, 8s, 10s). + * + * Also adds a per-request timeout so that hung connections (no response from + * the RPC server) fail fast rather than waiting indefinitely. */ export function newConnection( url: string, - config?: ConnectionConfig & { maxRetries?: number }, + config?: ConnectionConfig & { maxRetries?: number; requestTimeoutMs?: number }, ): Connection { const maxRetries = config?.maxRetries ?? DEFAULT_MAX_RETRIES; + const requestTimeoutMs = config?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const retryFetch = async ( input: Parameters[0], init?: Parameters[1], ): Promise => { for (let attempt = 0; ; attempt++) { - const response = await fetch(input, init); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), requestTimeoutMs); + let response: Response; + try { + response = await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } if (response.status !== 429 || attempt >= maxRetries) { return response; } diff --git a/sdk/serviceability/typescript/serviceability/state.ts b/sdk/serviceability/typescript/serviceability/state.ts index d8ac7ffba1..cc655df412 100644 --- a/sdk/serviceability/typescript/serviceability/state.ts +++ b/sdk/serviceability/typescript/serviceability/state.ts @@ -470,6 +470,11 @@ export function deserializeExchange(data: Uint8Array): Exchange { // Interface (versioned, embedded in Device) // --------------------------------------------------------------------------- +export interface FlexAlgoNodeSegment { + topology: PublicKey; + nodeSegmentIdx: number; +} + export interface DeviceInterface { version: number; status: number; @@ -486,9 +491,10 @@ export interface DeviceInterface { ipNet: Uint8Array; nodeSegmentIdx: number; userTunnelEndpoint: boolean; + flexAlgoNodeSegments?: FlexAlgoNodeSegment[]; } -const CURRENT_INTERFACE_VERSION = 2; +const CURRENT_INTERFACE_VERSION = 3; function deserializeInterface(r: DefensiveReader): DeviceInterface { const iface: DeviceInterface = { @@ -507,6 +513,7 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { ipNet: new Uint8Array(5), nodeSegmentIdx: 0, userTunnelEndpoint: false, + flexAlgoNodeSegments: [], }; iface.version = r.readU8(); @@ -524,8 +531,8 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); - } else if (iface.version === 1) { - // V2 + } else if (iface.version === 1 || iface.version === 2) { + // V2 or V3 iface.status = r.readU8(); iface.name = r.readString(); iface.interfaceType = r.readU8(); @@ -540,6 +547,16 @@ function deserializeInterface(r: DefensiveReader): DeviceInterface { iface.ipNet = r.readNetworkV4(); iface.nodeSegmentIdx = r.readU16(); iface.userTunnelEndpoint = r.readBool(); + // flex_algo_node_segments is part of V2 (version byte 1) and later + const segCount = r.readU32(); + const flexAlgoNodeSegments: FlexAlgoNodeSegment[] = []; + for (let i = 0; i < segCount; i++) { + flexAlgoNodeSegments.push({ + topology: readPubkey(r), + nodeSegmentIdx: r.readU16(), + }); + } + iface.flexAlgoNodeSegments = flexAlgoNodeSegments; } return iface; diff --git a/sdk/serviceability/typescript/serviceability/tests/compat.test.ts b/sdk/serviceability/typescript/serviceability/tests/compat.test.ts index b041bb5c59..e286d0d16a 100644 --- a/sdk/serviceability/typescript/serviceability/tests/compat.test.ts +++ b/sdk/serviceability/typescript/serviceability/tests/compat.test.ts @@ -13,7 +13,8 @@ import { describe, expect, test, setDefaultTimeout } from "bun:test"; // Compat tests hit public RPC endpoints which may be slow or rate-limited. -setDefaultTimeout(30_000); +// The getProgramData test fetches all mainnet accounts and can take 60-90s during busy periods. +setDefaultTimeout(120_000); import { Connection, PublicKey } from "@solana/web3.js"; import { PROGRAM_IDS, LEDGER_RPC_URLS } from "../config.js"; import { newConnection } from "../rpc.js"; diff --git a/smartcontract/cli/src/accesspass/get.rs b/smartcontract/cli/src/accesspass/get.rs index 6a3607bf6e..84d9eebfea 100644 --- a/smartcontract/cli/src/accesspass/get.rs +++ b/smartcontract/cli/src/accesspass/get.rs @@ -161,6 +161,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mgroup_pubkey = Pubkey::new_unique(); diff --git a/smartcontract/cli/src/device/interface/create.rs b/smartcontract/cli/src/device/interface/create.rs index c3408d7ed1..975898d4b1 100644 --- a/smartcontract/cli/src/device/interface/create.rs +++ b/smartcontract/cli/src/device/interface/create.rs @@ -223,6 +223,7 @@ mod tests { ip_net: "185.189.47.80/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -321,6 +322,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/cli/src/device/interface/delete.rs b/smartcontract/cli/src/device/interface/delete.rs index 80dbe70bc1..a3a0f674b5 100644 --- a/smartcontract/cli/src/device/interface/delete.rs +++ b/smartcontract/cli/src/device/interface/delete.rs @@ -109,6 +109,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 12, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -126,6 +127,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 13, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/cli/src/device/interface/get.rs b/smartcontract/cli/src/device/interface/get.rs index 7e512fce2b..44c26de1fe 100644 --- a/smartcontract/cli/src/device/interface/get.rs +++ b/smartcontract/cli/src/device/interface/get.rs @@ -136,6 +136,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/cli/src/device/interface/list.rs b/smartcontract/cli/src/device/interface/list.rs index 86c2b6a26a..c4df9c9c1d 100644 --- a/smartcontract/cli/src/device/interface/list.rs +++ b/smartcontract/cli/src/device/interface/list.rs @@ -164,6 +164,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 12, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -182,6 +183,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 13, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/cli/src/device/interface/update.rs b/smartcontract/cli/src/device/interface/update.rs index 5c31ab90a0..8dee27ee7b 100644 --- a/smartcontract/cli/src/device/interface/update.rs +++ b/smartcontract/cli/src/device/interface/update.rs @@ -242,6 +242,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -259,6 +260,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], @@ -376,6 +378,7 @@ mod tests { ip_net: "10.0.0.1/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -424,6 +427,7 @@ mod tests { ip_net: "185.189.47.80/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, @@ -520,6 +524,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface()], max_users: 255, diff --git a/smartcontract/cli/src/doublezerocommand.rs b/smartcontract/cli/src/doublezerocommand.rs index d9ce860377..873260bd0c 100644 --- a/smartcontract/cli/src/doublezerocommand.rs +++ b/smartcontract/cli/src/doublezerocommand.rs @@ -99,6 +99,11 @@ use doublezero_sdk::{ remove_administrator::RemoveAdministratorTenantCommand, update::UpdateTenantCommand, update_payment_status::UpdatePaymentStatusCommand, }, + topology::{ + backfill::BackfillTopologyCommand, clear::ClearTopologyCommand, + create::CreateTopologyCommand, delete::DeleteTopologyCommand, + list::ListTopologyCommand, + }, user::{ create::CreateUserCommand, create_subscribe::CreateSubscribeUserCommand, delete::DeleteUserCommand, get::GetUserCommand, list::ListUserCommand, @@ -107,7 +112,8 @@ use doublezero_sdk::{ }, telemetry::LinkLatencyStats, DZClient, Device, DoubleZeroClient, Exchange, GetGlobalConfigCommand, GetGlobalStateCommand, - GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, User, + GlobalConfig, GlobalState, Link, Location, MulticastGroup, ResourceExtensionOwned, + TopologyInfo, User, }; use doublezero_serviceability::state::{ accesspass::AccessPass, accountdata::AccountData, contributor::Contributor, @@ -336,6 +342,15 @@ pub trait CliCommand { cmd: GetResourceCommand, ) -> eyre::Result<(Pubkey, ResourceExtensionOwned)>; fn close_resource(&self, cmd: CloseResourceCommand) -> eyre::Result; + + fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)>; + fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result; + fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result; + fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result; + fn list_topology( + &self, + cmd: ListTopologyCommand, + ) -> eyre::Result>; } pub struct CliCommandImpl<'a> { @@ -799,4 +814,22 @@ impl CliCommand for CliCommandImpl<'_> { fn close_resource(&self, cmd: CloseResourceCommand) -> eyre::Result { cmd.execute(self.client) } + fn create_topology(&self, cmd: CreateTopologyCommand) -> eyre::Result<(Signature, Pubkey)> { + cmd.execute(self.client) + } + fn delete_topology(&self, cmd: DeleteTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn clear_topology(&self, cmd: ClearTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn backfill_topology(&self, cmd: BackfillTopologyCommand) -> eyre::Result { + cmd.execute(self.client) + } + fn list_topology( + &self, + cmd: ListTopologyCommand, + ) -> eyre::Result> { + cmd.execute(self.client) + } } diff --git a/smartcontract/cli/src/lib.rs b/smartcontract/cli/src/lib.rs index f9d4118273..4f330c9158 100644 --- a/smartcontract/cli/src/lib.rs +++ b/smartcontract/cli/src/lib.rs @@ -30,6 +30,7 @@ pub mod resource; pub mod subscribe; pub mod tenant; pub mod tests; +pub mod topology; pub mod user; pub mod util; pub mod validators; diff --git a/smartcontract/cli/src/link/accept.rs b/smartcontract/cli/src/link/accept.rs index 985f5421b8..f5804cd693 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -239,7 +239,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -251,6 +251,9 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index b50280354c..f39a069512 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -139,7 +139,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -151,6 +151,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/dzx_create.rs b/smartcontract/cli/src/link/dzx_create.rs index bb7d878449..d3c17a8cd0 100644 --- a/smartcontract/cli/src/link/dzx_create.rs +++ b/smartcontract/cli/src/link/dzx_create.rs @@ -363,6 +363,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/get.rs b/smartcontract/cli/src/link/get.rs index f23c4ef885..3f889034ed 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::link::get::GetLinkCommand; +use doublezero_sdk::{commands::link::get::GetLinkCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -47,6 +47,28 @@ struct LinkDisplay { pub status: String, pub health: String, pub owner: String, + pub link_topologies: String, + pub unicast_drained: bool, +} + +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } } impl GetLinkCliCommand { @@ -55,6 +77,10 @@ impl GetLinkCliCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = LinkDisplay { account: pubkey.to_string(), code: link.code, @@ -92,6 +118,10 @@ impl GetLinkCliCommand { status: link.status.to_string(), health: link.link_health.to_string(), owner: link.owner.to_string(), + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, }; if self.json { @@ -126,6 +156,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_link_get() { @@ -146,7 +177,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -158,6 +189,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let contributor = Contributor { @@ -240,6 +274,9 @@ mod tests { pubkey_or_code: device2_pk.to_string(), })) .returning(move |_| Ok((device2_pk, device2.clone()))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Expected failure let mut output = Vec::new(); @@ -293,7 +330,7 @@ mod tests { assert_eq!(json["status"].as_str().unwrap(), "activated"); assert_eq!(json["tunnel_type"].as_str().unwrap(), "WAN"); assert_eq!(json["bandwidth"].as_u64().unwrap(), 1_000_000_000); - assert_eq!(json["mtu"].as_u64().unwrap(), 9000); + assert_eq!(json["mtu"].as_u64().unwrap(), 1500); assert_eq!(json["contributor"].as_str().unwrap(), "test-contributor"); assert_eq!(json["side_a"].as_str().unwrap(), "side-a-device"); assert_eq!(json["side_z"].as_str().unwrap(), "side-z-device"); diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index 8e4a479cc9..963e43478a 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -178,7 +178,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, tunnel_id: 1, @@ -190,6 +190,8 @@ mod tests { delay_override_ns: 0, link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 39eaf7d061..e9e8b6bdce 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -6,13 +6,14 @@ use doublezero_sdk::{ contributor::{get::GetContributorCommand, list::ListContributorCommand}, device::list::ListDeviceCommand, link::list::ListLinkCommand, + topology::list::ListTopologyCommand, }, - Link, LinkLinkType, LinkStatus, + Link, LinkLinkType, LinkStatus, TopologyInfo, }; use doublezero_serviceability::state::link::{LinkDesiredStatus, LinkHealth}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{io::Write, str::FromStr}; +use std::{collections::HashMap, io::Write, str::FromStr}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -41,6 +42,9 @@ pub struct ListLinkCliCommand { /// Filter by link code (partial match) #[arg(long)] pub code: Option, + /// Filter by topology name (use "default" for links with no topology assignment) + #[arg(long)] + pub topology: Option, /// List only WAN links. #[arg(long, default_value_t = false)] pub wan: bool, @@ -92,6 +96,28 @@ pub struct LinkDisplay { pub health: LinkHealth, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, + pub link_topologies: String, + pub unicast_drained: bool, +} + +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } } impl ListLinkCliCommand { @@ -99,6 +125,9 @@ impl ListLinkCliCommand { let contributors = client.list_contributor(ListContributorCommand {})?; let devices = client.list_device(ListDeviceCommand)?; let mut links = client.list_link(ListLinkCommand)?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); // Filter by contributor if specified if let Some(contributor_filter) = &self.contributor { @@ -179,6 +208,20 @@ impl ListLinkCliCommand { links.retain(|(_, link)| link.code.contains(code_filter)); } + // Filter by topology if specified + if let Some(topology_filter) = &self.topology { + if topology_filter == "default" { + links.retain(|(_, link)| link.link_topologies.is_empty()); + } else { + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *topology_filter) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", topology_filter))?; + links.retain(|(_, link)| link.link_topologies.contains(&topology_pk)); + } + } + let mut tunnel_displays: Vec = links .into_iter() .map(|(pubkey, link)| { @@ -217,6 +260,10 @@ impl ListLinkCliCommand { status: link.status, health: link.link_health, owner: link.owner, + link_topologies: resolve_topology_names(&link.link_topologies, &topology_map), + unicast_drained: link.link_flags + & doublezero_serviceability::state::link::LINK_FLAG_UNICAST_DRAINED + != 0, } }) .collect(); @@ -377,6 +424,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -384,6 +434,9 @@ mod tests { tunnels.insert(tunnel1_pubkey, tunnel1.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -395,6 +448,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -404,7 +458,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 \n"); + assert_eq!(output_str, " account | code | contributor | side_a_name | side_a_iface_name | side_z_name | side_z_iface_name | link_type | bandwidth | mtu | delay_ms | jitter_ms | delay_override_ms | tunnel_id | tunnel_net | status | health | owner | link_topologies | unicast_drained \n 1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR | tunnel_code | contributor1_code | device2_code | eth0 | device2_code | eth1 | WAN | 10Gbps | 4500 | 0.02ms | 0.00ms | 0.00ms | 1234 | 1.2.3.4/32 | activated | ready-for-service | 11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9 | default | false \n"); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -416,6 +470,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -425,7 +480,7 @@ mod tests { assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\"}]\n"); + assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"code\":\"tunnel_code\",\"contributor_code\":\"contributor1_code\",\"side_a_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_a_name\":\"device2_code\",\"side_a_iface_name\":\"eth0\",\"side_z_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"side_z_name\":\"device2_code\",\"side_z_iface_name\":\"eth1\",\"link_type\":\"WAN\",\"bandwidth\":10000000000,\"mtu\":4500,\"delay_ns\":20000,\"jitter_ns\":1121,\"delay_override_ns\":0,\"tunnel_id\":1234,\"tunnel_net\":\"1.2.3.4/32\",\"desired_status\":\"Activated\",\"status\":\"Activated\",\"health\":\"ReadyForService\",\"owner\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"link_topologies\":\"default\",\"unicast_drained\":false}]\n"); } #[test] @@ -571,6 +626,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -583,7 +641,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 40_000, jitter_ns: 2000, delay_override_ns: 0, @@ -595,6 +653,9 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -603,6 +664,9 @@ mod tests { tunnels.insert(tunnel2_pubkey, tunnel2.clone()); Ok(tunnels) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); let mut output = Vec::new(); let res = ListLinkCliCommand { @@ -614,6 +678,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -743,6 +808,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -756,7 +824,7 @@ mod tests { side_z_pk: device2_pubkey, link_type: LinkLinkType::DZX, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -768,6 +836,9 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -776,6 +847,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by link_type=WAN (should return only link1) let mut output = Vec::new(); @@ -788,6 +862,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -916,6 +991,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -929,7 +1007,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -941,6 +1019,9 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -949,6 +1030,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by side_a=device_ams (should return only link1) let mut output = Vec::new(); @@ -961,6 +1045,7 @@ mod tests { health: None, desired_status: None, code: None, + topology: None, wan: false, dzx: false, json: false, @@ -1056,6 +1141,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1069,7 +1157,7 @@ mod tests { side_z_pk: device1_pubkey, link_type: LinkLinkType::WAN, bandwidth: 5_000_000_000, - mtu: 9000, + mtu: 1500, delay_ns: 10_000, jitter_ns: 500, delay_override_ns: 0, @@ -1081,6 +1169,9 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -1089,6 +1180,9 @@ mod tests { links.insert(link2_pubkey, link2.clone()); Ok(links) }); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); // Test filter by code=production (should return only link1) let mut output = Vec::new(); @@ -1101,6 +1195,7 @@ mod tests { health: None, desired_status: None, code: Some("production".to_string()), + topology: None, wan: false, dzx: false, json: false, diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index 90b9a5ac6a..62ec39956f 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -93,7 +93,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -105,6 +105,9 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let link2 = Link { @@ -117,7 +120,7 @@ mod tests { side_z_pk: device2_pk, link_type: LinkLinkType::WAN, bandwidth: 1000000000, - mtu: 9000, + mtu: 1500, delay_ns: 10000000000, jitter_ns: 5000000000, delay_override_ns: 0, @@ -129,6 +132,9 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index df36333011..e5ea6785a7 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -13,6 +13,7 @@ use doublezero_program_common::types::NetworkV4; use doublezero_sdk::commands::{ contributor::get::GetContributorCommand, link::{get::GetLinkCommand, update::UpdateLinkCommand}, + topology::list::ListTopologyCommand, }; use doublezero_serviceability::state::link::LinkDesiredStatus; use eyre::eyre; @@ -59,6 +60,12 @@ pub struct UpdateLinkCliCommand { /// Reassign tunnel network (foundation-only, e.g. 172.16.1.100/31) #[arg(long)] pub tunnel_net: Option, + /// Topology name to tag this link with (foundation-only). Use "default" to clear. + #[arg(long)] + pub link_topology: Option, + /// Mark this link as unicast-drained (contributor or foundation) + #[arg(long)] + pub unicast_drained: Option, /// Wait for the device to be activated #[arg(short, long, default_value_t = false)] pub wait: bool, @@ -115,6 +122,20 @@ impl UpdateLinkCliCommand { } } + let link_topologies = match self.link_topology { + None => None, + Some(ref name) if name == "default" => Some(vec![]), + Some(ref name) => { + let topology_map = client.list_topology(ListTopologyCommand)?; + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == *name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre!("Topology '{}' not found", name))?; + Some(vec![topology_pk]) + } + }; + let signature = client.update_link(UpdateLinkCommand { pubkey, code: self.code.clone(), @@ -133,6 +154,8 @@ impl UpdateLinkCliCommand { desired_status: self.desired_status, tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, + link_topologies, + unicast_drained: self.unicast_drained, })?; writeln!(out, "Signature: {signature}",)?; @@ -212,6 +235,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let link2 = Link { @@ -236,6 +261,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client @@ -282,6 +309,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topologies: None, + unicast_drained: None, })) .returning(move |_| Ok(signature)); @@ -301,6 +330,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); @@ -326,6 +357,8 @@ mod tests { desired_status: None, tunnel_id: None, tunnel_net: None, + link_topology: None, + unicast_drained: None, wait: false, } .execute(&client, &mut output); diff --git a/smartcontract/cli/src/link/wan_create.rs b/smartcontract/cli/src/link/wan_create.rs index 795ea121c5..fc09b3b10b 100644 --- a/smartcontract/cli/src/link/wan_create.rs +++ b/smartcontract/cli/src/link/wan_create.rs @@ -410,6 +410,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client diff --git a/smartcontract/cli/src/tenant/add_administrator.rs b/smartcontract/cli/src/tenant/add_administrator.rs index 290c700a23..00065095fb 100644 --- a/smartcontract/cli/src/tenant/add_administrator.rs +++ b/smartcontract/cli/src/tenant/add_administrator.rs @@ -83,6 +83,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/create.rs b/smartcontract/cli/src/tenant/create.rs index 9e73f76c00..36cd3aa167 100644 --- a/smartcontract/cli/src/tenant/create.rs +++ b/smartcontract/cli/src/tenant/create.rs @@ -109,6 +109,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/delete.rs b/smartcontract/cli/src/tenant/delete.rs index 0c6ba955da..7013a6e1c9 100644 --- a/smartcontract/cli/src/tenant/delete.rs +++ b/smartcontract/cli/src/tenant/delete.rs @@ -252,6 +252,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client @@ -311,6 +312,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client @@ -360,6 +362,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user = User { diff --git a/smartcontract/cli/src/tenant/get.rs b/smartcontract/cli/src/tenant/get.rs index 323011233c..fecd472d52 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -1,10 +1,10 @@ use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::get::GetTenantCommand; +use doublezero_sdk::{commands::tenant::get::GetTenantCommand, TopologyInfo}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::Tabled; #[derive(Args, Debug)] @@ -30,16 +30,41 @@ struct TenantDisplay { pub administrators: String, pub token_account: String, pub reference_count: u32, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl GetTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let (pubkey, tenant) = client.get_tenant(GetTenantCommand { pubkey_or_code: self.code, })?; + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand) + .unwrap_or_default(); + let display = TenantDisplay { account: pubkey, code: tenant.code, @@ -56,6 +81,7 @@ impl GetTenantCliCommand { .join(", "), token_account: tenant.token_account.to_string(), reference_count: tenant.reference_count, + include_topologies: resolve_topology_names(&tenant.include_topologies, &topology_map), owner: tenant.owner, }; @@ -84,6 +110,7 @@ mod tests { }; use mockall::predicate; use solana_sdk::pubkey::Pubkey; + use std::collections::HashMap; #[test] fn test_cli_tenant_get() { @@ -103,6 +130,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant_cloned = tenant.clone(); @@ -122,6 +150,9 @@ mod tests { client .expect_get_tenant() .returning(move |_| Err(eyre::eyre!("not found"))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ // Expected failure diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index acc2b72e79..356c6f3ea5 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -1,10 +1,13 @@ use crate::doublezerocommand::CliCommand; use clap::Args; use doublezero_program_common::serializer; -use doublezero_sdk::commands::tenant::list::ListTenantCommand; +use doublezero_sdk::{ + commands::{tenant::list::ListTenantCommand, topology::list::ListTopologyCommand}, + TopologyInfo, +}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{collections::HashMap, io::Write}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -25,13 +28,37 @@ pub struct TenantDisplay { pub vrf_id: u16, pub metro_routing: bool, pub route_liveness: bool, + pub include_topologies: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } +fn resolve_topology_names( + pubkeys: &[Pubkey], + topology_map: &HashMap, +) -> String { + if pubkeys.is_empty() { + "default".to_string() + } else { + pubkeys + .iter() + .map(|pk| { + topology_map + .get(pk) + .map(|t| t.name.clone()) + .unwrap_or_else(|| pk.to_string()) + }) + .collect::>() + .join(", ") + } +} + impl ListTenantCliCommand { pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { let tenants = client.list_tenant(ListTenantCommand {})?; + let topology_map = client + .list_topology(ListTopologyCommand) + .unwrap_or_default(); let mut tenant_displays: Vec = tenants .into_iter() @@ -41,6 +68,10 @@ impl ListTenantCliCommand { vrf_id: tenant.vrf_id, metro_routing: tenant.metro_routing, route_liveness: tenant.route_liveness, + include_topologies: resolve_topology_names( + &tenant.include_topologies, + &topology_map, + ), owner: tenant.owner, }) .collect(); @@ -91,11 +122,15 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client .expect_list_tenant() .returning(move |_| Ok(HashMap::from([(tenant1_pubkey, tenant1.clone())]))); + client + .expect_list_topology() + .returning(|_| Ok(HashMap::new())); /*****************************************************************************************************/ let mut output = Vec::new(); @@ -108,7 +143,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - " account | code | vrf_id | metro_routing | route_liveness | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" + " account | code | vrf_id | metro_routing | route_liveness | include_topologies | owner \n 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo | tenant-a | 100 | true | false | default | 11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo \n" ); let mut output = Vec::new(); @@ -121,7 +156,7 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!( output_str, - "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" + "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"code\":\"tenant-a\",\"vrf_id\":100,\"metro_routing\":true,\"route_liveness\":false,\"include_topologies\":\"default\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n" ); } } diff --git a/smartcontract/cli/src/tenant/remove_administrator.rs b/smartcontract/cli/src/tenant/remove_administrator.rs index 5e9deebbb9..db27bd8483 100644 --- a/smartcontract/cli/src/tenant/remove_administrator.rs +++ b/smartcontract/cli/src/tenant/remove_administrator.rs @@ -83,6 +83,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index a304eb559c..acaeb05bd7 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -4,7 +4,10 @@ use crate::{ validators::validate_pubkey_or_code, }; use clap::Args; -use doublezero_sdk::commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}; +use doublezero_sdk::{ + commands::tenant::{get::GetTenantCommand, update::UpdateTenantCommand}, + get_topology_pda, +}; use doublezero_serviceability::state::tenant::{FlatPerEpochConfig, TenantBillingConfig}; use solana_sdk::pubkey::Pubkey; use std::{io::Write, str::FromStr}; @@ -29,6 +32,9 @@ pub struct UpdateTenantCliCommand { /// Flat billing rate per epoch (in lamports) #[arg(long)] pub billing_rate: Option, + /// Comma-separated topology names to assign to this tenant (foundation-only). Use "default" to clear. + #[arg(long)] + pub include_topologies: Option, } impl UpdateTenantCliCommand { @@ -54,6 +60,28 @@ impl UpdateTenantCliCommand { }) }); + let include_topologies = if let Some(ref topo_arg) = self.include_topologies { + if topo_arg == "default" { + Some(vec![]) + } else { + let program_id = client.get_program_id(); + let pubkeys: eyre::Result> = topo_arg + .split(',') + .map(|name| { + let name = name.trim(); + let pda = get_topology_pda(&program_id, name).0; + client + .get_account(pda) + .map_err(|_| eyre::eyre!("Topology '{}' not found", name))?; + Ok(pda) + }) + .collect(); + Some(pubkeys?) + } + } else { + None + }; + let signature = client.update_tenant(UpdateTenantCommand { tenant_pubkey, vrf_id: self.vrf_id, @@ -61,6 +89,7 @@ impl UpdateTenantCliCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing, + include_topologies, })?; writeln!(out, "Signature: {signature}")?; @@ -111,6 +140,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client @@ -132,6 +162,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing: None, + include_topologies: None, })) .returning(move |_| Ok(signature)); @@ -144,6 +175,7 @@ mod tests { metro_routing: Some(true), route_liveness: None, billing_rate: None, + include_topologies: None, } .execute(&client, &mut output); assert!(res.is_ok()); diff --git a/smartcontract/cli/src/tenant/update_payment_status.rs b/smartcontract/cli/src/tenant/update_payment_status.rs index b5843b093e..2d579c4bd8 100644 --- a/smartcontract/cli/src/tenant/update_payment_status.rs +++ b/smartcontract/cli/src/tenant/update_payment_status.rs @@ -84,6 +84,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/topology/backfill.rs b/smartcontract/cli/src/topology/backfill.rs new file mode 100644 index 0000000000..6bb31a1ac0 --- /dev/null +++ b/smartcontract/cli/src/topology/backfill.rs @@ -0,0 +1,43 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::backfill::BackfillTopologyCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct BackfillTopologyCliCommand { + /// Name of the topology to backfill + #[arg(long)] + pub name: String, + /// Device account pubkeys to backfill (one or more) + #[arg(long = "device", value_name = "PUBKEY")] + pub device_pubkeys: Vec, +} + +impl BackfillTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + if self.device_pubkeys.is_empty() { + return Err(eyre::eyre!( + "at least one --device pubkey is required for backfill" + )); + } + + let sig = client.backfill_topology(BackfillTopologyCommand { + name: self.name.clone(), + device_pubkeys: self.device_pubkeys, + })?; + + writeln!( + out, + "Backfilled topology '{}'. Signature: {}", + self.name, sig + )?; + + Ok(()) + } +} diff --git a/smartcontract/cli/src/topology/clear.rs b/smartcontract/cli/src/topology/clear.rs new file mode 100644 index 0000000000..928ce995c9 --- /dev/null +++ b/smartcontract/cli/src/topology/clear.rs @@ -0,0 +1,262 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::clear::ClearTopologyCommand; +use solana_sdk::pubkey::Pubkey; +use std::io::Write; + +// Solana transactions have a 32-account limit. With 3 fixed accounts (topology PDA, +// globalstate, payer), we can fit at most 29 link accounts per transaction. +const CLEAR_BATCH_SIZE: usize = 29; + +#[derive(Args, Debug)] +pub struct ClearTopologyCliCommand { + /// Name of the topology to clear from links + #[arg(long)] + pub name: String, + /// Comma-separated list of link pubkeys to clear the topology from. + /// If omitted, all links currently tagged with this topology are discovered automatically. + #[arg(long, value_delimiter = ',')] + pub links: Vec, +} + +impl ClearTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + let link_pubkeys: Vec = if self.links.is_empty() { + // Auto-discover: find all links tagged with this topology. + let topology_map = client + .list_topology(doublezero_sdk::commands::topology::list::ListTopologyCommand)?; + let topology_pk = topology_map + .iter() + .find(|(_, t)| t.name == self.name) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Topology '{}' not found", self.name))?; + + let links = client.list_link(doublezero_sdk::commands::link::list::ListLinkCommand)?; + links + .into_iter() + .filter(|(_, link)| link.link_topologies.contains(&topology_pk)) + .map(|(pk, _)| pk) + .collect() + } else { + self.links + .iter() + .map(|s| { + s.parse::() + .map_err(|_| eyre::eyre!("invalid link pubkey: {}", s)) + }) + .collect::>>()? + }; + + let total = link_pubkeys.len(); + + if total == 0 { + writeln!( + out, + "No links tagged with topology '{}'. Nothing to clear.", + self.name + )?; + return Ok(()); + } + + // Batch into chunks that fit within Solana's account limit. + for chunk in link_pubkeys.chunks(CLEAR_BATCH_SIZE) { + client.clear_topology(ClearTopologyCommand { + name: self.name.clone(), + link_pubkeys: chunk.to_vec(), + })?; + } + + writeln!( + out, + "Cleared topology '{}' from {} link(s).", + self.name, total + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use doublezero_sdk::{Link, TopologyInfo}; + use mockall::predicate::eq; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::{collections::HashMap, io::Cursor}; + + #[test] + fn test_clear_topology_execute_no_links_auto_discover_empty() { + // When links is empty, auto-discovery runs but finds no tagged links. + let mut mock = MockCliCommand::new(); + let topology_pk = Pubkey::new_unique(); + + let mut topology_map: HashMap = HashMap::new(); + topology_map.insert( + topology_pk, + TopologyInfo { + account_type: doublezero_sdk::AccountType::Topology, + owner: Pubkey::default(), + bump_seed: 0, + name: "unicast-default".to_string(), + admin_group_bit: 1, + flex_algo_number: 129, + constraint: + doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }, + ); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(move |_| Ok(topology_map.clone())); + mock.expect_list_link().returning(|_| Ok(HashMap::new())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("No links tagged with topology 'unicast-default'.")); + } + + #[test] + fn test_clear_topology_execute_with_links() { + let mut mock = MockCliCommand::new(); + let link1 = Pubkey::new_unique(); + let link2 = Pubkey::new_unique(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_clear_topology() + .with(eq(ClearTopologyCommand { + name: "unicast-default".to_string(), + link_pubkeys: vec![link1, link2], + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec![link1.to_string(), link2.to_string()], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'unicast-default' from 2 link(s).")); + } + + #[test] + fn test_clear_topology_invalid_pubkey() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + + let cmd = ClearTopologyCliCommand { + name: "unicast-default".to_string(), + links: vec!["not-a-pubkey".to_string()], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + } + + #[test] + fn test_clear_topology_auto_discover() { + let mut mock = MockCliCommand::new(); + let topology_pk = Pubkey::new_unique(); + let other_topology_pk = Pubkey::new_unique(); + let link1_pk = Pubkey::new_unique(); + let link2_pk = Pubkey::new_unique(); + let untagged_pk = Pubkey::new_unique(); + + let mut topology_map: HashMap = HashMap::new(); + topology_map.insert( + topology_pk, + TopologyInfo { + account_type: doublezero_sdk::AccountType::Topology, + owner: Pubkey::default(), + bump_seed: 0, + name: "my-topo".to_string(), + admin_group_bit: 1, + flex_algo_number: 129, + constraint: + doublezero_serviceability::state::topology::TopologyConstraint::IncludeAny, + }, + ); + + let mut links: HashMap = HashMap::new(); + links.insert( + link1_pk, + Link { + link_topologies: vec![topology_pk], + ..Default::default() + }, + ); + links.insert( + link2_pk, + Link { + link_topologies: vec![topology_pk, other_topology_pk], + ..Default::default() + }, + ); + links.insert( + untagged_pk, + Link { + link_topologies: vec![], + ..Default::default() + }, + ); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(move |_| Ok(topology_map.clone())); + mock.expect_list_link() + .returning(move |_| Ok(links.clone())); + mock.expect_clear_topology() + .withf(move |cmd| { + cmd.name == "my-topo" + && cmd.link_pubkeys.len() == 2 + && cmd.link_pubkeys.contains(&link1_pk) + && cmd.link_pubkeys.contains(&link2_pk) + }) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = ClearTopologyCliCommand { + name: "my-topo".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok(), "{:?}", result); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Cleared topology 'my-topo' from 2 link(s).")); + } + + #[test] + fn test_clear_topology_auto_discover_not_found() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_list_topology() + .returning(|_| Ok(HashMap::new())); + + let cmd = ClearTopologyCliCommand { + name: "nonexistent".to_string(), + links: vec![], + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Topology 'nonexistent' not found")); + } +} diff --git a/smartcontract/cli/src/topology/create.rs b/smartcontract/cli/src/topology/create.rs new file mode 100644 index 0000000000..0fb83a2fec --- /dev/null +++ b/smartcontract/cli/src/topology/create.rs @@ -0,0 +1,123 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::commands::topology::create::CreateTopologyCommand; +use doublezero_serviceability::state::topology::TopologyConstraint; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct CreateTopologyCliCommand { + /// Name of the topology (max 32 bytes) + #[arg(long)] + pub name: String, + /// Constraint type: include-any or exclude + #[arg(long, value_parser = parse_constraint)] + pub constraint: TopologyConstraint, +} + +fn parse_constraint(s: &str) -> Result { + match s { + "include-any" => Ok(TopologyConstraint::IncludeAny), + "exclude" => Ok(TopologyConstraint::Exclude), + _ => Err(format!( + "invalid constraint '{}': expected 'include-any' or 'exclude'", + s + )), + } +} + +impl CreateTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + if self.name.len() > 32 { + eyre::bail!( + "topology name must be 32 characters or fewer (got {})", + self.name.len() + ); + } + + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + let (_, topology_pda) = client.create_topology(CreateTopologyCommand { + name: self.name.clone(), + constraint: self.constraint, + })?; + writeln!( + out, + "Created topology '{}' successfully. PDA: {}", + self.name, topology_pda + )?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::doublezerocommand::MockCliCommand; + use doublezero_serviceability::state::topology::TopologyConstraint; + use mockall::predicate::eq; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::io::Cursor; + + #[test] + fn test_create_topology_execute_success() { + let mut mock = MockCliCommand::new(); + let topology_pda = Pubkey::new_unique(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_create_topology() + .with(eq(CreateTopologyCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + })) + .returning(move |_| Ok((Signature::new_unique(), topology_pda))); + + let cmd = CreateTopologyCliCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Created topology 'unicast-default' successfully.")); + assert!(output.contains(&topology_pda.to_string())); + } + + #[test] + fn test_parse_constraint_include_any() { + assert_eq!( + parse_constraint("include-any"), + Ok(TopologyConstraint::IncludeAny) + ); + } + + #[test] + fn test_parse_constraint_exclude() { + assert_eq!(parse_constraint("exclude"), Ok(TopologyConstraint::Exclude)); + } + + #[test] + fn test_parse_constraint_invalid() { + assert!(parse_constraint("unknown").is_err()); + } + + #[test] + fn test_create_topology_name_too_long() { + let cmd = CreateTopologyCliCommand { + name: "a".repeat(33), + constraint: TopologyConstraint::IncludeAny, + }; + let mock = MockCliCommand::new(); + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("32 characters or fewer")); + } +} diff --git a/smartcontract/cli/src/topology/delete.rs b/smartcontract/cli/src/topology/delete.rs new file mode 100644 index 0000000000..d92cd7a661 --- /dev/null +++ b/smartcontract/cli/src/topology/delete.rs @@ -0,0 +1,141 @@ +use crate::{ + doublezerocommand::CliCommand, + requirements::{CHECK_BALANCE, CHECK_ID_JSON}, +}; +use clap::Args; +use doublezero_sdk::{ + commands::{link::list::ListLinkCommand, topology::delete::DeleteTopologyCommand}, + get_topology_pda, +}; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct DeleteTopologyCliCommand { + /// Name of the topology to delete + #[arg(long)] + pub name: String, +} + +impl DeleteTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + client.check_requirements(CHECK_ID_JSON | CHECK_BALANCE)?; + + // Guard: check if any links still reference this topology + let program_id = client.get_program_id(); + let topology_pda = get_topology_pda(&program_id, &self.name).0; + let links = client.list_link(ListLinkCommand)?; + let referencing_count = links + .values() + .filter(|link| link.link_topologies.contains(&topology_pda)) + .count(); + if referencing_count > 0 { + return Err(eyre::eyre!( + "Cannot delete topology '{}': {} link(s) still reference it. Run 'doublezero link topology clear --name {}' first.", + self.name, + referencing_count, + self.name, + )); + } + + client.delete_topology(DeleteTopologyCommand { + name: self.name.clone(), + })?; + writeln!(out, "Deleted topology '{}' successfully.", self.name)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{doublezerocommand::MockCliCommand, tests::utils::create_test_client}; + use doublezero_sdk::{ + commands::topology::delete::DeleteTopologyCommand, get_topology_pda, Link, LinkLinkType, + LinkStatus, + }; + use doublezero_serviceability::state::{ + accounttype::AccountType, + link::{LinkDesiredStatus, LinkHealth}, + }; + use mockall::predicate::eq; + use solana_sdk::{pubkey::Pubkey, signature::Signature}; + use std::{collections::HashMap, io::Cursor}; + + #[test] + fn test_delete_topology_execute_success() { + let mut mock = MockCliCommand::new(); + + mock.expect_check_requirements().returning(|_| Ok(())); + mock.expect_get_program_id() + .returning(|| Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah")); + mock.expect_list_link().returning(|_| Ok(HashMap::new())); + mock.expect_delete_topology() + .with(eq(DeleteTopologyCommand { + name: "unicast-default".to_string(), + })) + .returning(|_| Ok(Signature::new_unique())); + + let cmd = DeleteTopologyCliCommand { + name: "unicast-default".to_string(), + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("Deleted topology 'unicast-default' successfully.")); + } + + #[test] + fn test_delete_topology_blocked_by_referencing_links() { + let mut client = create_test_client(); + + client.expect_check_requirements().returning(|_| Ok(())); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let topology_pda = get_topology_pda(&program_id, "unicast-default").0; + + let link = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "link1".to_string(), + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 0, + jitter_ns: 0, + delay_override_ns: 0, + tunnel_id: 1, + tunnel_net: "10.0.0.0/30".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::new_unique(), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![topology_pda], + link_flags: 0, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(Pubkey::new_unique(), link.clone()); + Ok(links) + }); + + let cmd = DeleteTopologyCliCommand { + name: "unicast-default".to_string(), + }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Cannot delete topology 'unicast-default'")); + assert!(err.contains("1 link(s) still reference it")); + assert!(err.contains("doublezero link topology clear --name unicast-default")); + } +} diff --git a/smartcontract/cli/src/topology/list.rs b/smartcontract/cli/src/topology/list.rs new file mode 100644 index 0000000000..fa57dcccf0 --- /dev/null +++ b/smartcontract/cli/src/topology/list.rs @@ -0,0 +1,263 @@ +use crate::doublezerocommand::CliCommand; +use clap::Args; +use doublezero_sdk::{ + commands::{link::list::ListLinkCommand, topology::list::ListTopologyCommand}, + get_topology_pda, +}; +use serde::Serialize; +use std::io::Write; + +#[derive(Args, Debug)] +pub struct ListTopologyCliCommand { + /// Output as pretty JSON. + #[arg(long, default_value_t = false)] + pub json: bool, +} + +#[derive(Serialize)] +pub struct TopologyDisplay { + pub name: String, + pub bit: u8, + pub algo: u8, + pub color: u16, + pub constraint: String, + pub links: usize, +} + +impl ListTopologyCliCommand { + pub fn execute(self, client: &C, out: &mut W) -> eyre::Result<()> { + let topologies = client.list_topology(ListTopologyCommand)?; + + if topologies.is_empty() { + writeln!(out, "No topologies found.")?; + return Ok(()); + } + + let links = client.list_link(ListLinkCommand)?; + let program_id = client.get_program_id(); + + let mut entries: Vec<_> = topologies.into_iter().collect(); + entries.sort_by_key(|(_, t)| t.admin_group_bit); + + let displays: Vec = entries + .iter() + .map(|(pda, t)| { + // Count how many links reference this topology PDA. The topology_pda + // is derived from name, but we already have the PDA as the map key. + let _ = get_topology_pda(&program_id, &t.name); // kept for symmetry; use key directly + let link_count = links + .values() + .filter(|link| link.link_topologies.contains(pda)) + .count(); + TopologyDisplay { + name: t.name.clone(), + bit: t.admin_group_bit, + algo: t.flex_algo_number, + color: t.admin_group_bit as u16 + 1, + constraint: format!("{:?}", t.constraint), + links: link_count, + } + }) + .collect(); + + if self.json { + serde_json::to_writer_pretty(&mut *out, &displays)?; + writeln!(out)?; + return Ok(()); + } + + writeln!( + out, + "{:<32} {:>3} {:>4} {:>5} {:>5} {:?}", + "NAME", "BIT", "ALGO", "COLOR", "LINKS", "CONSTRAINT" + )?; + for d in &displays { + writeln!( + out, + "{:<32} {:>3} {:>4} {:>5} {:>5} {}", + d.name, d.bit, d.algo, d.color, d.links, d.constraint, + )?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{doublezerocommand::MockCliCommand, tests::utils::create_test_client}; + use doublezero_sdk::{get_topology_pda, Link, LinkLinkType, LinkStatus}; + use doublezero_serviceability::state::{ + accounttype::AccountType, + link::{LinkDesiredStatus, LinkHealth}, + topology::{TopologyConstraint, TopologyInfo}, + }; + use solana_sdk::pubkey::Pubkey; + use std::{collections::HashMap, io::Cursor}; + + #[test] + fn test_list_topology_empty() { + let mut mock = MockCliCommand::new(); + + mock.expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand { json: false }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&mock, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("No topologies found.")); + } + + #[test] + fn test_list_topology_with_entries() { + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + client + .expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(topology_pda, topology.clone()); + Ok(map) + }); + + client.expect_list_link().returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand { json: false }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + assert!(output.contains("unicast-default")); + assert!(output.contains("128")); + assert!(output.contains("LINKS")); + } + + #[test] + fn test_list_topology_link_count() { + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + client + .expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(topology_pda, topology.clone()); + Ok(map) + }); + + let link = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "link1".to_string(), + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 0, + jitter_ns: 0, + delay_override_ns: 0, + tunnel_id: 1, + tunnel_net: "10.0.0.0/30".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::new_unique(), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![topology_pda], + link_flags: 0, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(Pubkey::new_unique(), link.clone()); + Ok(links) + }); + + let cmd = ListTopologyCliCommand { json: false }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + // The link count should be 1 + assert!( + output.contains(" 1"), + "expected link count 1 in output: {output}" + ); + } + + #[test] + fn test_list_topology_json_output() { + let mut client = create_test_client(); + + let program_id = Pubkey::from_str_const("GYhQDKuESrasNZGyhMJhGYFtbzNijYhcrN9poSqCQVah"); + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + client + .expect_list_topology() + .with(mockall::predicate::eq(ListTopologyCommand)) + .returning(move |_| { + let mut map = HashMap::new(); + map.insert(topology_pda, topology.clone()); + Ok(map) + }); + + client.expect_list_link().returning(|_| Ok(HashMap::new())); + + let cmd = ListTopologyCliCommand { json: true }; + let mut out = Cursor::new(Vec::new()); + let result = cmd.execute(&client, &mut out); + assert!(result.is_ok()); + let output = String::from_utf8(out.into_inner()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).expect("valid JSON"); + assert!(parsed.is_array()); + let arr = parsed.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "unicast-default"); + assert_eq!(arr[0]["links"], 0); + } +} diff --git a/smartcontract/cli/src/topology/mod.rs b/smartcontract/cli/src/topology/mod.rs new file mode 100644 index 0000000000..01fa6fd760 --- /dev/null +++ b/smartcontract/cli/src/topology/mod.rs @@ -0,0 +1,5 @@ +pub mod backfill; +pub mod clear; +pub mod create; +pub mod delete; +pub mod list; diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index 790a266d73..481a17a94f 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -180,6 +180,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let device_pubkey = Pubkey::new_unique(); @@ -372,6 +373,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let device_pubkey = Pubkey::new_unique(); diff --git a/smartcontract/cli/src/user/list.rs b/smartcontract/cli/src/user/list.rs index af5e4ddede..731197be35 100644 --- a/smartcontract/cli/src/user/list.rs +++ b/smartcontract/cli/src/user/list.rs @@ -1463,6 +1463,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1478,6 +1479,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); @@ -1638,6 +1640,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1653,6 +1656,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 555fac6e0f..6c33335624 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -97,6 +97,10 @@ use crate::{ remove_administrator::process_remove_administrator_tenant, update::process_update_tenant, update_payment_status::process_update_payment_status, }, + topology::{ + backfill::process_topology_backfill, clear::process_topology_clear, + create::process_topology_create, delete::process_topology_delete, + }, user::{ activate::process_activate_user, ban::process_ban_user, check_access_pass::process_check_access_pass_user, @@ -431,6 +435,18 @@ pub fn process_instruction( DoubleZeroInstruction::SetUserBGPStatus(value) => { process_set_bgp_status_user(program_id, accounts, &value)? } + DoubleZeroInstruction::CreateTopology(value) => { + process_topology_create(program_id, accounts, &value)? + } + DoubleZeroInstruction::DeleteTopology(value) => { + process_topology_delete(program_id, accounts, &value)? + } + DoubleZeroInstruction::ClearTopology(value) => { + process_topology_clear(program_id, accounts, &value)? + } + DoubleZeroInstruction::BackfillTopology(value) => { + process_topology_backfill(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index fbd60d4b1f..38a0081c21 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -80,6 +80,10 @@ use crate::processors::{ delete::TenantDeleteArgs, remove_administrator::TenantRemoveAdministratorArgs, update::TenantUpdateArgs, update_payment_status::UpdatePaymentStatusArgs, }, + topology::{ + backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, + delete::TopologyDeleteArgs, + }, user::{ activate::UserActivateArgs, ban::UserBanArgs, check_access_pass::CheckUserAccessPassArgs, closeaccount::UserCloseAccountArgs, create::UserCreateArgs, @@ -224,6 +228,10 @@ pub enum DoubleZeroInstruction { CreateIndex(IndexCreateArgs), // variant 104 DeleteIndex(IndexDeleteArgs), // variant 105 SetUserBGPStatus(SetUserBGPStatusArgs), // variant 106 + CreateTopology(TopologyCreateArgs), // variant 107 + DeleteTopology(TopologyDeleteArgs), // variant 108 + ClearTopology(TopologyClearArgs), // variant 109 + BackfillTopology(TopologyBackfillArgs), // variant 110 } impl DoubleZeroInstruction { @@ -355,10 +363,13 @@ impl DoubleZeroInstruction { 100 => Ok(Self::ResumePermission(PermissionResumeArgs::try_from(rest).unwrap())), 101 => Ok(Self::DeletePermission(PermissionDeleteArgs::try_from(rest).unwrap())), - 104 => Ok(Self::CreateIndex(IndexCreateArgs::try_from(rest).unwrap())), 105 => Ok(Self::DeleteIndex(IndexDeleteArgs::try_from(rest).unwrap())), 106 => Ok(Self::SetUserBGPStatus(SetUserBGPStatusArgs::try_from(rest).unwrap())), + 107 => Ok(Self::CreateTopology(TopologyCreateArgs::try_from(rest).unwrap())), + 108 => Ok(Self::DeleteTopology(TopologyDeleteArgs::try_from(rest).unwrap())), + 109 => Ok(Self::ClearTopology(TopologyClearArgs::try_from(rest).unwrap())), + 110 => Ok(Self::BackfillTopology(TopologyBackfillArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -497,6 +508,10 @@ impl DoubleZeroInstruction { Self::CreateIndex(_) => "CreateIndex".to_string(), // variant 104 Self::DeleteIndex(_) => "DeleteIndex".to_string(), // variant 105 Self::SetUserBGPStatus(_) => "SetUserBGPStatus".to_string(), // variant 106 + Self::CreateTopology(_) => "CreateTopology".to_string(), // variant 107 + Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 + Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 + Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 110 } } @@ -627,6 +642,10 @@ impl DoubleZeroInstruction { Self::CreateIndex(args) => format!("{args:?}"), // variant 104 Self::DeleteIndex(args) => format!("{args:?}"), // variant 105 Self::SetUserBGPStatus(args) => format!("{args:?}"), // variant 106 + Self::CreateTopology(args) => format!("{args:?}"), // variant 107 + Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 + Self::ClearTopology(args) => format!("{args:?}"), // variant 109 + Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 } } } @@ -826,6 +845,8 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, + unicast_drained: None, }), "UpdateLink", ); @@ -1276,6 +1297,7 @@ mod tests { metro_routing: Some(true), route_liveness: Some(false), billing: None, + include_topologies: None, }), "UpdateTenant", ); @@ -1322,5 +1344,24 @@ mod tests { }), "SetUserBGPStatus", ); + test_instruction( + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: crate::state::topology::TopologyConstraint::IncludeAny, + }), + "CreateTopology", + ); + test_instruction( + DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: "unicast-default".to_string(), + }), + "DeleteTopology", + ); + test_instruction( + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "unicast-default".to_string(), + }), + "ClearTopology", + ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index 7e8e0e1bcf..51b058f85d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -4,12 +4,12 @@ use solana_program::pubkey::Pubkey; use crate::{ seeds::{ - SEED_ACCESS_PASS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, SEED_DEVICE_TUNNEL_BLOCK, - SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, SEED_INDEX, SEED_LINK, - SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, - SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, SEED_PROGRAM_CONFIG, - SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TUNNEL_IDS, SEED_USER, SEED_USER_TUNNEL_BLOCK, - SEED_VRF_IDS, + SEED_ACCESS_PASS, SEED_ADMIN_GROUP_BITS, SEED_CONFIG, SEED_CONTRIBUTOR, SEED_DEVICE, + SEED_DEVICE_TUNNEL_BLOCK, SEED_DZ_PREFIX_BLOCK, SEED_EXCHANGE, SEED_GLOBALSTATE, + SEED_INDEX, SEED_LINK, SEED_LINK_IDS, SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, + SEED_MULTICAST_GROUP, SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PERMISSION, SEED_PREFIX, + SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TENANT, SEED_TOPOLOGY, SEED_TUNNEL_IDS, + SEED_USER, SEED_USER_TUNNEL_BLOCK, SEED_VRF_IDS, }, state::user::UserType, }; @@ -116,6 +116,10 @@ pub fn get_index_pda(program_id: &Pubkey, entity_seed: &[u8], key: &str) -> (Pub ) } +pub fn get_topology_pda(program_id: &Pubkey, name: &str) -> (Pubkey, u8) { + Pubkey::find_program_address(&[SEED_PREFIX, SEED_TOPOLOGY, name.as_bytes()], program_id) +} + pub fn get_resource_extension_pda( program_id: &Pubkey, resource_type: crate::resource::ResourceType, @@ -182,5 +186,10 @@ pub fn get_resource_extension_pda( Pubkey::find_program_address(&[SEED_PREFIX, SEED_VRF_IDS], program_id); (pda, bump_seed, SEED_VRF_IDS) } + crate::resource::ResourceType::AdminGroupBits => { + let (pda, bump_seed) = + Pubkey::find_program_address(&[SEED_PREFIX, SEED_ADMIN_GROUP_BITS], program_id); + (pda, bump_seed, SEED_ADMIN_GROUP_BITS) + } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs index 6e2e0453ce..3abe78ced9 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/create.rs @@ -236,6 +236,7 @@ pub fn process_create_device_interface( ip_net, node_segment_idx, user_tunnel_endpoint: value.user_tunnel_endpoint, + flex_algo_node_segments: vec![], } .to_interface(), ); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs index 5ce66bd448..c4f41e6645 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs @@ -65,6 +65,7 @@ pub fn process_set_globalconfig( let segment_routing_ids_account = next_account_info(accounts_iter)?; let multicast_publisher_block_account = next_account_info(accounts_iter)?; let vrf_ids_account = next_account_info(accounts_iter)?; + let admin_group_bits_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; @@ -104,6 +105,8 @@ pub fn process_set_globalconfig( get_resource_extension_pda(program_id, ResourceType::MulticastGroupBlock); let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(program_id, ResourceType::MulticastPublisherBlock); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::AdminGroupBits); assert_eq!( device_tunnel_block_account.key, &device_tunnel_block_pda, @@ -125,6 +128,11 @@ pub fn process_set_globalconfig( "Invalid Multicast Publisher Block PubKey" ); + assert_eq!( + admin_group_bits_account.key, &admin_group_bits_pda, + "Invalid AdminGroupBits PubKey" + ); + let next_bgp_community = if let Some(val) = value.next_bgp_community { val } else if pda_account.try_borrow_data()?.is_empty() { @@ -230,6 +238,16 @@ pub fn process_set_globalconfig( accounts, ResourceType::VrfIds, )?; + + create_resource( + program_id, + admin_group_bits_account, + None, + pda_account, + payer_account, + accounts, + ResourceType::AdminGroupBits, + )?; } else { let old_data = GlobalConfig::try_from(pda_account)?; if old_data.device_tunnel_block != data.device_tunnel_block { @@ -266,6 +284,20 @@ pub fn process_set_globalconfig( ResourceType::MulticastPublisherBlock, )?; } + + // Create AdminGroupBits PDA if it doesn't exist yet (migration support for + // deployments that predate RFC-18). + if admin_group_bits_account.data_is_empty() { + create_resource( + program_id, + admin_group_bits_account, + None, + pda_account, + payer_account, + accounts, + ResourceType::AdminGroupBits, + )?; + } } #[cfg(test)] diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index d43b4b3571..ec442d6778 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -1,6 +1,6 @@ use crate::{ error::DoubleZeroError, - pda::get_resource_extension_pda, + pda::{get_resource_extension_pda, get_topology_pda}, processors::{ resource::{allocate_id, allocate_ip}, validation::validate_program_account, @@ -59,11 +59,11 @@ pub fn process_activate_link( let side_z_device_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; - // Optional: ResourceExtension accounts for on-chain allocation (before payer) + // Optional: ResourceExtension accounts for on-chain allocation (before unicast-default topology) // Account layout WITH ResourceExtension (use_onchain_allocation = true): - // [link, side_a_dev, side_z_dev, globalstate, device_tunnel_block, link_ids, payer, system] + // [link, side_a_dev, side_z_dev, globalstate, device_tunnel_block, link_ids, unicast_default, payer, system] // Account layout WITHOUT (legacy, use_onchain_allocation = false): - // [link, side_a_dev, side_z_dev, globalstate, payer, system] + // [link, side_a_dev, side_z_dev, globalstate, unicast_default, payer, system] let resource_extension_accounts = if value.use_onchain_allocation { let device_tunnel_block_ext = next_account_info(accounts_iter)?; // DeviceTunnelBlock (global) let link_ids_ext = next_account_info(accounts_iter)?; // LinkIds (global) @@ -72,6 +72,7 @@ pub fn process_activate_link( None }; + let unicast_default_topology_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -231,6 +232,16 @@ pub fn process_activate_link( link.check_status_transition(); + // Auto-tag with UNICAST-DEFAULT topology at activation + let (expected_unicast_default_pda, _) = get_topology_pda(program_id, "unicast-default"); + if unicast_default_topology_account.owner != program_id + || unicast_default_topology_account.key != &expected_unicast_default_pda + || unicast_default_topology_account.data_is_empty() + { + return Err(DoubleZeroError::InvalidArgument.into()); + } + link.link_topologies = vec![*unicast_default_topology_account.key]; + try_acc_write(&side_a_dev, side_a_device_account, payer_account, accounts)?; try_acc_write(&side_z_dev, side_z_device_account, payer_account, accounts)?; try_acc_write(&link, link_account, payer_account, accounts)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs index dc32780a36..12ea02aca9 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -90,6 +90,10 @@ pub fn process_create_link( // Check if the payer is a signer assert!(payer_account.is_signer, "Payer must be a signer"); + if value.mtu != LINK_MTU { + return Err(DoubleZeroError::InvalidMtu.into()); + } + // Validate and normalize code let mut code = validate_account_code(&value.code).map_err(|_| DoubleZeroError::InvalidAccountCode)?; @@ -189,10 +193,6 @@ pub fn process_create_link( return Err(DoubleZeroError::InvalidInterfaceZForExternal.into()); } - if value.mtu != LINK_MTU { - return Err(DoubleZeroError::InvalidMtu.into()); - } - let status = if value.link_type == LinkLinkType::DZX { LinkStatus::Requested } else { @@ -227,6 +227,8 @@ pub fn process_create_link( // link_health: LinkHealth::Pending, link_health: LinkHealth::ReadyForService, // Force the link to be ready for service until the health oracle is implemented, desired_status: value.desired_status.unwrap_or(LinkDesiredStatus::Activated), + link_topologies: Vec::new(), + link_flags: 0, }; link.check_status_transition(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs index 16fa4c677d..00f26c5455 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/update.rs @@ -1,5 +1,5 @@ use crate::{ - error::DoubleZeroError, + error::{DoubleZeroError, Validate}, pda::get_resource_extension_pda, processors::{ resource::{allocate_specific_id, allocate_specific_ip, deallocate_id, deallocate_ip}, @@ -13,6 +13,7 @@ use crate::{ feature_flags::{is_feature_enabled, FeatureFlag}, globalstate::GlobalState, link::*, + topology::TopologyInfo, }, }; use borsh::BorshSerialize; @@ -43,6 +44,9 @@ pub struct LinkUpdateArgs { pub tunnel_net: Option, #[incremental(default = false)] pub use_onchain_allocation: bool, + pub link_topologies: Option>, + #[incremental(default = None)] + pub unicast_drained: Option, } impl fmt::Debug for LinkUpdateArgs { @@ -87,6 +91,12 @@ impl fmt::Debug for LinkUpdateArgs { if self.use_onchain_allocation { parts.push("use_onchain_allocation: true".to_string()); } + if let Some(ref link_topologies) = self.link_topologies { + parts.push(format!("link_topologies: {:?}", link_topologies)); + } + if let Some(unicast_drained) = self.unicast_drained { + parts.push(format!("unicast_drained: {:?}", unicast_drained)); + } write!(f, "{}", parts.join(", ")) } } @@ -112,6 +122,11 @@ pub fn process_update_link( if value.use_onchain_allocation { expected_without_side_z += 2; // device_tunnel_block, link_ids } + // Topology accounts are passed as trailing accounts after system_program. + // Include them in the expected count so side_z detection is not confused. + if let Some(ref link_topologies) = value.link_topologies { + expected_without_side_z += link_topologies.len(); + } let side_z_account: Option<&AccountInfo> = if accounts.len() > expected_without_side_z { Some(next_account_info(accounts_iter)?) } else { @@ -137,6 +152,7 @@ pub fn process_update_link( let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; + let topology_accounts: Vec<&AccountInfo> = accounts_iter.collect(); #[cfg(test)] msg!("process_update_link({:?})", value); @@ -219,9 +235,6 @@ pub fn process_update_link( link.bandwidth = bandwidth; } if let Some(mtu) = value.mtu { - if mtu != crate::state::interface::LINK_MTU { - return Err(DoubleZeroError::InvalidMtu.into()); - } link.mtu = mtu; } if let Some(delay_ns) = value.delay_ns { @@ -364,7 +377,49 @@ pub fn process_update_link( try_acc_write(&side_z_dev, device_z_account, payer_account, accounts)?; } + // link_topologies is foundation-only + if let Some(link_topologies) = &value.link_topologies { + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("link_topologies update requires foundation allowlist"); + return Err(DoubleZeroError::NotAllowed.into()); + } + if link_topologies.len() > 8 { + msg!("link_topologies exceeds maximum of 8 entries"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + if link_topologies.len() != topology_accounts.len() { + msg!("link_topologies count does not match provided topology accounts"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + for (pk, acc) in link_topologies.iter().zip(topology_accounts.iter()) { + if acc.key != pk || acc.owner != program_id || acc.data_is_empty() { + return Err(DoubleZeroError::InvalidArgument.into()); + } + TopologyInfo::try_from(*acc) + .map_err(|_| DoubleZeroError::InvalidAccountType)? + .validate() + .map_err(ProgramError::from)?; + } + link.link_topologies = link_topologies.clone(); + } + + // unicast_drained (LINK_FLAG_UNICAST_DRAINED bit 0): contributor A or foundation + if let Some(unicast_drained) = value.unicast_drained { + if link.contributor_pk != *contributor_account.key + && !globalstate.foundation_allowlist.contains(payer_account.key) + { + msg!("unicast_drained update requires contributor A or foundation allowlist"); + return Err(DoubleZeroError::NotAllowed.into()); + } + if unicast_drained { + link.link_flags |= crate::state::link::LINK_FLAG_UNICAST_DRAINED; + } else { + link.link_flags &= !crate::state::link::LINK_FLAG_UNICAST_DRAINED; + } + } + link.check_status_transition(); + link.validate()?; try_acc_write(&link, link_account, payer_account, accounts)?; @@ -419,6 +474,8 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, + unicast_drained: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); @@ -472,6 +529,8 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, + unicast_drained: None, }; let serialized = borsh::to_vec(&args_before).unwrap(); diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs index 129390202c..4a4d4ec73b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/mod.rs @@ -13,5 +13,6 @@ pub mod multicastgroup; pub mod permission; pub mod resource; pub mod tenant; +pub mod topology; pub mod user; pub mod validation; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs index 6838ba8c47..565171f171 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs @@ -69,6 +69,7 @@ pub fn get_resource_extension_range( ResourceType::LinkIds => ResourceExtensionRange::IdRange(0, 65535), ResourceType::SegmentRoutingIds => ResourceExtensionRange::IdRange(1, 65535), ResourceType::VrfIds => ResourceExtensionRange::IdRange(1, 1024), + ResourceType::AdminGroupBits => ResourceExtensionRange::IdRange(1, 127), } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs index 45cb623c6d..8475e08d76 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/create.rs @@ -139,6 +139,7 @@ pub fn process_create_tenant( metro_routing: value.metro_routing, route_liveness: value.route_liveness, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let deposit = Rent::get() diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs index 138cf15d07..877b4e0006 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs @@ -11,13 +11,11 @@ use borsh_incremental::BorshDeserializeIncremental; use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, + msg, pubkey::Pubkey, }; use std::fmt; -#[cfg(test)] -use solana_program::msg; - #[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] pub struct TenantUpdateArgs { pub vrf_id: Option, @@ -25,14 +23,16 @@ pub struct TenantUpdateArgs { pub metro_routing: Option, pub route_liveness: Option, pub billing: Option, + #[incremental(default = None)] + pub include_topologies: Option>, } impl fmt::Debug for TenantUpdateArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "vrf_id: {:?}, token_account: {:?}, metro_routing: {:?}, route_liveness: {:?}, billing: {:?}", - self.vrf_id, self.token_account, self.metro_routing, self.route_liveness, self.billing + "vrf_id: {:?}, token_account: {:?}, metro_routing: {:?}, route_liveness: {:?}, billing: {:?}, include_topologies: {:?}", + self.vrf_id, self.token_account, self.metro_routing, self.route_liveness, self.billing, self.include_topologies ) } } @@ -94,6 +94,13 @@ pub fn process_update_tenant( if let Some(billing) = value.billing { tenant.billing = billing; } + if let Some(ref topologies) = value.include_topologies { + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TenantUpdate: include_topologies requires foundation key"); + return Err(DoubleZeroError::NotAllowed.into()); + } + tenant.include_topologies = topologies.clone(); + } try_acc_write(&tenant, tenant_account, payer_account, accounts)?; Ok(()) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs new file mode 100644 index 0000000000..4bef8b0176 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs @@ -0,0 +1,174 @@ +use crate::{ + error::DoubleZeroError, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::resource::{allocate_id, allocate_specific_id}, + resource::ResourceType, + serializer::try_acc_write, + state::{ + device::Device, + globalstate::GlobalState, + interface::{Interface, LoopbackType}, + topology::FlexAlgoNodeSegment, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyBackfillArgs { + pub name: String, +} + +/// Backfill FlexAlgoNodeSegment entries on existing Vpnv4 loopbacks for an +/// already-created topology. Idempotent — skips loopbacks that already have +/// an entry for this topology. +/// +/// Accounts layout: +/// [0] topology PDA (readonly — must already exist) +/// [1] segment_routing_ids (writable, ResourceExtension) +/// [2] globalstate (readonly) +/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [4+] Device accounts (writable) +pub fn process_topology_backfill( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyBackfillArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let segment_routing_ids_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_backfill(name={})", value.name); + + if !payer_account.is_signer { + msg!("TopologyBackfill: payer must be a signer"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyBackfill: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + if topology_account.key != &expected_pda { + msg!( + "TopologyBackfill: invalid topology PDA for name '{}'", + value.name + ); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + if topology_account.data_is_empty() { + msg!("TopologyBackfill: topology '{}' does not exist", value.name); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + // Validate SegmentRoutingIds account + let (expected_sr_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + if segment_routing_ids_account.key != &expected_sr_pda { + msg!("TopologyBackfill: invalid SegmentRoutingIds PDA"); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + let topology_key = topology_account.key; + let mut backfilled_count: usize = 0; + let mut skipped_count: usize = 0; + + // Collect device accounts for two-pass processing. + let device_accounts: Vec<&AccountInfo> = accounts_iter.collect(); + + // First pass: pre-mark all existing node_segment_idx values as used in the + // SegmentRoutingIds resource. This prevents collisions when the activator + // manages SR IDs in-memory (use_onchain_allocation=false) and the on-chain + // resource hasn't been updated to reflect those allocations. + for device_account in &device_accounts { + if device_account.owner != program_id { + continue; + } + let device = match Device::try_from(&device_account.data.borrow()[..]) { + Ok(d) => d, + Err(_) => continue, + }; + for iface in device.interfaces.iter() { + let current = iface.into_current_version(); + if current.node_segment_idx > 0 { + // Ignore error: ID may already be marked (idempotent pre-mark). + let _ = allocate_specific_id(segment_routing_ids_account, current.node_segment_idx); + } + for fas in ¤t.flex_algo_node_segments { + let _ = allocate_specific_id(segment_routing_ids_account, fas.node_segment_idx); + } + } + } + + // Second pass: allocate new IDs for loopbacks missing this topology's segment. + for device_account in &device_accounts { + if device_account.owner != program_id { + continue; + } + let mut device = match Device::try_from(&device_account.data.borrow()[..]) { + Ok(d) => d, + Err(_) => continue, + }; + let mut modified = false; + for iface in device.interfaces.iter_mut() { + let iface_v2 = iface.into_current_version(); + if iface_v2.loopback_type != LoopbackType::Vpnv4 { + continue; + } + // Skip if already has a segment for this topology (idempotent) + if iface_v2 + .flex_algo_node_segments + .iter() + .any(|s| &s.topology == topology_key) + { + skipped_count += 1; + continue; + } + let node_segment_idx = allocate_id(segment_routing_ids_account)?; + match iface { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + } + _ => { + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + *iface = Interface::V2(upgraded); + } + } + modified = true; + backfilled_count += 1; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + + msg!( + "TopologyBackfill: '{}' — {} loopback(s) backfilled, {} already had segment", + value.name, + backfilled_count, + skipped_count + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs new file mode 100644 index 0000000000..6c84f57a31 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs @@ -0,0 +1,90 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + serializer::try_acc_write, + state::{globalstate::GlobalState, link::Link}, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyClearArgs { + pub name: String, +} + +/// Accounts layout: +/// [0] topology PDA (readonly, for key validation) +/// [1] globalstate (readonly) +/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [3+] Link accounts (writable) — remove topology pubkey from link_topologies on each +pub fn process_topology_clear( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyClearArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_clear(name={})", value.name); + + // Payer must be a signer + if !payer_account.is_signer { + msg!("TopologyClear: payer must be a signer"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyClear: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyClear: invalid topology PDA for name '{}'", + value.name + ); + + // We don't require the topology to still exist (it may already be closed). + // The validation above confirms the key matches the expected PDA for the name. + + let topology_key = topology_account.key; + let mut cleared_count: usize = 0; + + // Process remaining Link accounts: remove topology key from link_topologies + for link_account in accounts_iter { + if link_account.data_is_empty() { + continue; + } + let mut link = match Link::try_from(link_account) { + Ok(l) => l, + Err(_) => continue, + }; + let before_len = link.link_topologies.len(); + link.link_topologies.retain(|k| k != topology_key); + if link.link_topologies.len() < before_len { + try_acc_write(&link, link_account, payer_account, accounts)?; + cleared_count += 1; + } + } + + msg!( + "TopologyClear: removed topology '{}' from {} link(s)", + value.name, + cleared_count + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs new file mode 100644 index 0000000000..0b9aaaefce --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -0,0 +1,199 @@ +use crate::{ + error::DoubleZeroError, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::{resource::allocate_id, validation::validate_program_account}, + resource::ResourceType, + seeds::{SEED_PREFIX, SEED_TOPOLOGY}, + serializer::{try_acc_create, try_acc_write}, + state::{ + accounttype::AccountType, + device::Device, + globalstate::GlobalState, + interface::{Interface, LoopbackType}, + topology::{FlexAlgoNodeSegment, TopologyConstraint, TopologyInfo}, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program_error::ProgramError, + pubkey::Pubkey, +}; + +pub const MAX_TOPOLOGY_NAME_LEN: usize = 32; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyCreateArgs { + pub name: String, + pub constraint: TopologyConstraint, +} + +/// Accounts layout: +/// [0] topology PDA (writable, to be created) +/// [1] admin_group_bits (writable, ResourceExtension) +/// [2] globalstate (readonly) +/// [3] payer (writable, signer, must be in foundation_allowlist) +/// [4] system_program +/// [5] segment_routing_ids (writable, ResourceExtension) — only if Vpnv4 loopbacks passed +/// [6+] Vpnv4 loopback Interface accounts (writable) — optional, for backfill +/// +/// If no Vpnv4 loopbacks are passed, account [5] can be omitted. +pub fn process_topology_create( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyCreateArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + let topology_account = next_account_info(accounts_iter)?; + let admin_group_bits_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + let system_program = next_account_info(accounts_iter)?; + + assert!(payer_account.is_signer, "Payer account must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(&globalstate_account.data.borrow()[..])?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyCreate: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate name length + if value.name.len() > MAX_TOPOLOGY_NAME_LEN { + msg!( + "TopologyCreate: name exceeds {} bytes", + MAX_TOPOLOGY_NAME_LEN + ); + return Err(DoubleZeroError::InvalidArgument.into()); + } + + // Validate and verify topology PDA + let (expected_pda, bump_seed) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyCreate: invalid topology PDA for name '{}'", + value.name + ); + + if !topology_account.data_is_empty() { + msg!("TopologyCreate: topology '{}' already exists", value.name); + return Err(ProgramError::AccountAlreadyInitialized); + } + + // Validate AdminGroupBits resource account + let (expected_ab_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::AdminGroupBits); + validate_program_account!( + admin_group_bits_account, + program_id, + writable = true, + pda = &expected_ab_pda, + "AdminGroupBits" + ); + + // Allocate admin_group_bit (lowest available bit in IdRange) + let admin_group_bit = allocate_id(admin_group_bits_account)? as u8; + let flex_algo_number = 128u8 + .checked_add(admin_group_bit) + .ok_or(DoubleZeroError::ArithmeticOverflow)?; + + // Create the topology PDA account + let topology = TopologyInfo { + account_type: AccountType::Topology, + owner: *payer_account.key, + bump_seed, + name: value.name.clone(), + admin_group_bit, + flex_algo_number, + constraint: value.constraint, + }; + + try_acc_create( + &topology, + topology_account, + payer_account, + system_program, + program_id, + &[ + SEED_PREFIX, + SEED_TOPOLOGY, + value.name.as_bytes(), + &[bump_seed], + ], + )?; + + // Backfill Vpnv4 loopbacks (remaining accounts after system_program) + // Convention: if any Device accounts are passed, segment_routing_ids must be + // the last account; Device accounts precede it. + let remaining: Vec<&AccountInfo> = accounts_iter.collect(); + if !remaining.is_empty() { + let (device_accounts, tail) = remaining.split_at(remaining.len() - 1); + let segment_routing_ids_account = tail[0]; + + // Validate the SegmentRoutingIds account + let (expected_sr_pda, _, _) = + crate::pda::get_resource_extension_pda(program_id, ResourceType::SegmentRoutingIds); + assert_eq!( + segment_routing_ids_account.key, &expected_sr_pda, + "TopologyCreate: invalid SegmentRoutingIds PDA" + ); + + for device_account in device_accounts { + if device_account.owner != program_id { + continue; + } + let mut device = Device::try_from(&device_account.data.borrow()[..])?; + let mut modified = false; + for iface in device.interfaces.iter_mut() { + let iface_v2 = iface.into_current_version(); + if iface_v2.loopback_type != LoopbackType::Vpnv4 { + continue; + } + // Skip if already has a segment for this topology (idempotent) + if iface_v2 + .flex_algo_node_segments + .iter() + .any(|s| &s.topology == topology_account.key) + { + continue; + } + let node_segment_idx = allocate_id(segment_routing_ids_account)?; + // Mutate the interface in place — upgrade to V2 if needed + match iface { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + } + _ => { + // Upgrade to current version (V2) with the segment added + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + *iface = Interface::V2(upgraded); + } + } + modified = true; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + } + + msg!( + "TopologyCreate: created '{}' bit={} algo={} constraint={:?}", + value.name, + admin_group_bit, + flex_algo_number, + value.constraint + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs new file mode 100644 index 0000000000..e70e318237 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs @@ -0,0 +1,88 @@ +use crate::{ + error::DoubleZeroError, + pda::get_topology_pda, + serializer::try_acc_close, + state::{globalstate::GlobalState, link::Link, topology::TopologyInfo}, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, Debug, Clone, PartialEq)] +pub struct TopologyDeleteArgs { + pub name: String, +} + +/// Accounts layout: +/// [0] topology PDA (writable, to be closed) +/// [1] globalstate (readonly) +/// [2] payer (writable, signer, must be in foundation_allowlist) +/// [3+] Link accounts (readonly) — guard: fail if any references this topology +pub fn process_topology_delete( + program_id: &Pubkey, + accounts: &[AccountInfo], + value: &TopologyDeleteArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let topology_account = next_account_info(accounts_iter)?; + let globalstate_account = next_account_info(accounts_iter)?; + let payer_account = next_account_info(accounts_iter)?; + + #[cfg(test)] + msg!("process_topology_delete(name={})", value.name); + + // Payer must be a signer + assert!(payer_account.is_signer, "Payer must be a signer"); + + // Authorization: foundation keys only + let globalstate = GlobalState::try_from(globalstate_account)?; + if !globalstate.foundation_allowlist.contains(payer_account.key) { + msg!("TopologyDelete: unauthorized — foundation key required"); + return Err(DoubleZeroError::Unauthorized.into()); + } + + // Validate topology PDA + let (expected_pda, _) = get_topology_pda(program_id, &value.name); + assert_eq!( + topology_account.key, &expected_pda, + "TopologyDelete: invalid topology PDA for name '{}'", + value.name + ); + + // Deserialize topology to get its pubkey for reference checks + let _topology = TopologyInfo::try_from(topology_account)?; + + // Check remaining Link accounts — fail if any reference this topology + for link_account in accounts_iter { + if link_account.data_is_empty() { + continue; + } + if let Ok(link) = Link::try_from(link_account) { + if link.link_topologies.contains(topology_account.key) { + msg!( + "TopologyDelete: link {} still references topology {}", + link_account.key, + topology_account.key + ); + return Err(DoubleZeroError::ReferenceCountNotZero.into()); + } + } + } + + // Close the topology PDA (transfer lamports to payer, zero data) + // NOTE: We do NOT deallocate the admin-group bit — bits are permanently retired. + // If a bit were reused for a new topology, any IS-IS router still advertising + // link memberships for the deleted topology would classify traffic onto the new + // topology's flex-algo path until the network fully converges, causing misrouting. + // Admin-group bits are a cheap resource (128 total), so permanent allocation is safe. + try_acc_close(topology_account, payer_account)?; + + msg!("TopologyDelete: closed topology '{}'", value.name); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs new file mode 100644 index 0000000000..b45b525a64 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs @@ -0,0 +1,4 @@ +pub mod backfill; +pub mod clear; +pub mod create; +pub mod delete; diff --git a/smartcontract/programs/doublezero-serviceability/src/resource.rs b/smartcontract/programs/doublezero-serviceability/src/resource.rs index 79de501b0b..9b2bdf95b7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/resource.rs +++ b/smartcontract/programs/doublezero-serviceability/src/resource.rs @@ -15,6 +15,7 @@ pub enum ResourceType { LinkIds, SegmentRoutingIds, VrfIds, + AdminGroupBits, } impl fmt::Display for ResourceType { @@ -29,6 +30,7 @@ impl fmt::Display for ResourceType { ResourceType::LinkIds => write!(f, "LinkIds"), ResourceType::SegmentRoutingIds => write!(f, "SegmentRoutingIds"), ResourceType::VrfIds => write!(f, "VrfIds"), + ResourceType::AdminGroupBits => write!(f, "AdminGroupBits"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index 4ef9626dd9..d4d98eae6b 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -21,4 +21,6 @@ pub const SEED_LINK_IDS: &[u8] = b"linkids"; pub const SEED_SEGMENT_ROUTING_IDS: &[u8] = b"segmentroutingids"; pub const SEED_VRF_IDS: &[u8] = b"vrfids"; pub const SEED_PERMISSION: &[u8] = b"permission"; +pub const SEED_ADMIN_GROUP_BITS: &[u8] = b"admingroupbits"; pub const SEED_INDEX: &[u8] = b"index"; +pub const SEED_TOPOLOGY: &[u8] = b"topology"; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs index 89fb6ac3c8..58c6e4c544 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accountdata.rs @@ -5,7 +5,7 @@ use crate::{ exchange::Exchange, globalconfig::GlobalConfig, globalstate::GlobalState, index::Index, link::Link, location::Location, multicastgroup::MulticastGroup, permission::Permission, programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, - user::User, + topology::TopologyInfo, user::User, }, }; use solana_program::program_error::ProgramError; @@ -30,6 +30,7 @@ pub enum AccountData { Tenant(Tenant), Permission(Permission), Index(Index), + Topology(TopologyInfo), } impl AccountData { @@ -51,6 +52,7 @@ impl AccountData { AccountData::Tenant(_) => "Tenant", AccountData::Permission(_) => "Permission", AccountData::Index(_) => "Index", + AccountData::Topology(_) => "Topology", } } @@ -72,6 +74,7 @@ impl AccountData { AccountData::Tenant(tenant) => tenant.to_string(), AccountData::Permission(permission) => permission.to_string(), AccountData::Index(index) => index.to_string(), + AccountData::Topology(topology) => topology.to_string(), } } @@ -194,6 +197,14 @@ impl AccountData { Err(DoubleZeroError::InvalidAccountType) } } + + pub fn get_topology(&self) -> Result { + if let AccountData::Topology(topology) = self { + Ok(topology.clone()) + } else { + Err(DoubleZeroError::InvalidAccountType) + } + } } impl TryFrom<&[u8]> for AccountData { @@ -236,6 +247,9 @@ impl TryFrom<&[u8]> for AccountData { bytes as &[u8], )?)), AccountType::Index => Ok(AccountData::Index(Index::try_from(bytes as &[u8])?)), + AccountType::Topology => Ok(AccountData::Topology(TopologyInfo::try_from( + bytes as &[u8], + )?)), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs index 24e8430bab..a4e8501aa2 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/accounttype.rs @@ -24,6 +24,7 @@ pub enum AccountType { Tenant = 13, Permission = 15, Index = 16, + Topology = 17, } pub trait AccountTypeInfo { @@ -52,6 +53,7 @@ impl From for AccountType { 13 => AccountType::Tenant, 15 => AccountType::Permission, 16 => AccountType::Index, + 17 => AccountType::Topology, _ => AccountType::None, } } @@ -76,6 +78,7 @@ impl fmt::Display for AccountType { AccountType::Tenant => write!(f, "tenant"), AccountType::Permission => write!(f, "permission"), AccountType::Index => write!(f, "index"), + AccountType::Topology => write!(f, "topology"), } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/device.rs b/smartcontract/programs/doublezero-serviceability/src/state/device.rs index b39bec5be5..e4b6824c74 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/device.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/device.rs @@ -1049,6 +1049,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(); let val = Device { @@ -1119,6 +1120,7 @@ mod tests { ip_net: "10.0.0.1/24".parse().unwrap(), node_segment_idx: 42, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(), CurrentInterfaceVersion { @@ -1136,6 +1138,7 @@ mod tests { ip_net: "10.0.1.1/24".parse().unwrap(), node_segment_idx: 24, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } .to_interface(), ], diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index c82811f9fb..5fc3613578 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -308,6 +308,7 @@ pub struct InterfaceV2 { pub ip_net: NetworkV4, // 4 IPv4 address + 1 subnet mask pub node_segment_idx: u16, // 2 pub user_tunnel_endpoint: bool, // 1 + pub flex_algo_node_segments: Vec, } impl InterfaceV2 { @@ -320,7 +321,7 @@ impl InterfaceV2 { } pub fn size_given_name_len(name_len: usize) -> usize { - 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 4 // +4 for empty flex_algo_node_segments vec (Borsh length prefix) } } @@ -346,6 +347,10 @@ impl TryFrom<&[u8]> for InterfaceV2 { let val: u8 = BorshDeserialize::deserialize(&mut data).unwrap_or_default(); val != 0 }, + // flex_algo_node_segments was added in the same version as this field set. + // Old on-chain V2 accounts (written before this field existed) will have no + // trailing bytes here — unwrap_or_default() yields an empty vec. + flex_algo_node_segments: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }) } } @@ -363,12 +368,13 @@ impl TryFrom<&InterfaceV1> for InterfaceV2 { loopback_type: data.loopback_type, bandwidth: 0, cir: 0, - mtu: INTERFACE_MTU, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: data.vlan_id, ip_net: data.ip_net, node_segment_idx: data.node_segment_idx, user_tunnel_endpoint: data.user_tunnel_endpoint, + flex_algo_node_segments: vec![], }) } } @@ -384,12 +390,13 @@ impl Default for InterfaceV2 { loopback_type: LoopbackType::None, bandwidth: 0, cir: 0, - mtu: INTERFACE_MTU, + mtu: 1500, routing_mode: RoutingMode::Static, vlan_id: 0, ip_net: NetworkV4::default(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } } } @@ -480,8 +487,11 @@ impl TryFrom<&[u8]> for Interface { fn try_from(mut data: &[u8]) -> Result { match BorshDeserialize::deserialize(&mut data) { - Ok(0) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), - _ => Ok(Interface::V1(InterfaceV1::default())), // Default case + Ok(0u8) => Ok(Interface::V1(InterfaceV1::try_from(data)?)), + // Discriminant 1 = V2 (current). Discriminant 2 was the old V3 which has + // identical layout to V2 — treat it as V2 for backward compatibility. + Ok(1u8) | Ok(2u8) => Ok(Interface::V2(InterfaceV2::try_from(data)?)), + _ => Ok(Interface::V2(InterfaceV2::default())), } } } @@ -528,6 +538,7 @@ fn test_interface_version() { ip_net: "10.0.0.0/24".parse().unwrap(), node_segment_idx: 200, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], } .to_interface(); @@ -566,6 +577,7 @@ mod test_interface_validate { ip_net: NetworkV4::default(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } } @@ -671,6 +683,7 @@ mod test_interface_validate { ip_net: "203.0.113.40/32".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: true, + flex_algo_node_segments: vec![], }; // Serialize as Interface::V2 (with enum discriminant) diff --git a/smartcontract/programs/doublezero-serviceability/src/state/link.rs b/smartcontract/programs/doublezero-serviceability/src/state/link.rs index 2a2025ddea..49371567cd 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/link.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/link.rs @@ -264,14 +264,20 @@ pub struct Link { pub delay_override_ns: u64, // 8 pub link_health: LinkHealth, // 1 pub desired_status: LinkDesiredStatus, // 1 + pub link_topologies: Vec, // 4 + 32 * len + pub link_flags: u8, // 1 — bitmask; see LINK_FLAG_* constants } +/// Bit 0 of `link_flags`: link is administratively drained from unicast traffic. +/// Maps to IS-IS admin-group UNICAST-DRAINED (group 0). +pub const LINK_FLAG_UNICAST_DRAINED: u8 = 0x01; + impl fmt::Display for Link { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}", - self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status + "account_type: {}, owner: {}, index: {}, side_a_pk: {}, side_z_pk: {}, tunnel_type: {}, bandwidth: {}, mtu: {}, delay_ns: {}, jitter_ns: {}, tunnel_id: {}, tunnel_net: {}, status: {}, code: {}, contributor_pk: {}, link_health: {}, desired_status: {}, link_topologies: {:?}, link_flags: {:#04x}", + self.account_type, self.owner, self.index, self.side_a_pk, self.side_z_pk, self.link_type, self.bandwidth, self.mtu, self.delay_ns, self.jitter_ns, self.tunnel_id, &self.tunnel_net, self.status, self.code, self.contributor_pk, self.link_health, self.desired_status, self.link_topologies, self.link_flags ) } } @@ -300,6 +306,8 @@ impl Default for Link { delay_override_ns: 0, link_health: LinkHealth::Pending, desired_status: LinkDesiredStatus::Pending, + link_topologies: Vec::new(), + link_flags: 0, } } } @@ -330,6 +338,8 @@ impl TryFrom<&[u8]> for Link { delay_override_ns: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), link_health: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), desired_status: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + link_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + link_flags: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Link { @@ -404,6 +414,14 @@ impl Validate for Link { msg!("Invalid link endpoints: side_a_pk and side_z_pk must be different"); return Err(DoubleZeroError::InvalidDevicePubkey); } + // A link may belong to at most 8 topologies + if self.link_topologies.len() > 8 { + msg!( + "link_topologies exceeds maximum of 8 (got {})", + self.link_topologies.len() + ); + return Err(DoubleZeroError::InvalidArgument); + } Ok(()) } } @@ -426,6 +444,10 @@ impl Link { /// This method mutates the `status` field of the `Link` in-place. /// Where `_` means any value is valid for that field. /// + pub fn is_unicast_drained(&self) -> bool { + self.link_flags & LINK_FLAG_UNICAST_DRAINED != 0 + } + #[allow(unreachable_code)] pub fn check_status_transition(&mut self) { // waiting for health oracle to implement this logic @@ -549,6 +571,8 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let data = borsh::to_vec(&val).unwrap(); @@ -602,6 +626,8 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -632,6 +658,8 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -662,6 +690,8 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; // For Rejected status, tunnel_net is not validated and should succeed @@ -692,6 +722,8 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err = val.validate(); assert!(err.is_err()); @@ -722,6 +754,8 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -760,6 +794,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -799,6 +835,8 @@ mod tests { delay_override_ns: 0, link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err = val.validate(); @@ -830,6 +868,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -868,6 +908,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let err_low = val_low.validate(); assert!(err_low.is_err()); @@ -906,7 +948,46 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; assert!(bad_link.validate().is_ok()); } + + #[test] + fn test_state_link_validate_error_too_many_topologies() { + let valid_link = Link { + account_type: AccountType::Link, + owner: Pubkey::new_unique(), + index: 123, + bump_seed: 1, + contributor_pk: Pubkey::new_unique(), + side_a_pk: Pubkey::new_unique(), + side_z_pk: Pubkey::new_unique(), + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 1566, + delay_ns: 1_000_000, + jitter_ns: 1_000_000, + tunnel_id: 1, + tunnel_net: "10.0.0.1/25".parse().unwrap(), + code: "test-123".to_string(), + status: LinkStatus::Activated, + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + delay_override_ns: 0, + link_health: LinkHealth::ReadyForService, + desired_status: LinkDesiredStatus::Activated, + link_topologies: (0..8).map(|_| Pubkey::new_unique()).collect(), + link_flags: 0, + }; + assert!(valid_link.validate().is_ok()); + + let too_many = Link { + link_topologies: (0..9).map(|_| Pubkey::new_unique()).collect(), + ..valid_link + }; + let err = too_many.validate(); + assert_eq!(err.unwrap_err(), DoubleZeroError::InvalidArgument); + } } diff --git a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs index bbf02d13a1..dda96c197d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/mod.rs @@ -16,4 +16,5 @@ pub mod permission; pub mod programconfig; pub mod resource_extension; pub mod tenant; +pub mod topology; pub mod user; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs index 588ac6d847..7923e22c39 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/tenant.rs @@ -110,14 +110,22 @@ pub struct Tenant { pub metro_routing: bool, // 1 byte - enables tenant to be routed through metro for VRF requests pub route_liveness: bool, // 1 byte - enables tenant to be check for aliveness before routing pub billing: TenantBillingConfig, // 17 bytes (1 discriminant + 8 rate + 8 last_deduction_dz_epoch) + #[cfg_attr( + feature = "serde", + serde( + serialize_with = "doublezero_program_common::serializer::serialize_pubkeylist_as_string", + deserialize_with = "doublezero_program_common::serializer::deserialize_pubkeylist_from_string" + ) + )] + pub include_topologies: Vec, // 4 + (32 * len) — foundation-only: flex-algo topologies for unicast VPN route steering } impl fmt::Display for Tenant { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, bump_seed: {}, code: {}, vrf_id: {}, administrators: {:?}, payment_status: {}, token_account: {}, metro_routing: {}, route_liveness: {}, billing: {}", - self.account_type, self.owner, self.bump_seed, self.code, self.vrf_id, self.administrators, self.payment_status, self.token_account, self.metro_routing, self.route_liveness, self.billing + "account_type: {}, owner: {}, bump_seed: {}, code: {}, vrf_id: {}, administrators: {:?}, payment_status: {}, token_account: {}, metro_routing: {}, route_liveness: {}, billing: {}, include_topologies: {:?}", + self.account_type, self.owner, self.bump_seed, self.code, self.vrf_id, self.administrators, self.payment_status, self.token_account, self.metro_routing, self.route_liveness, self.billing, self.include_topologies ) } } @@ -139,6 +147,7 @@ impl TryFrom<&[u8]> for Tenant { metro_routing: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), route_liveness: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), billing: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + include_topologies: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::Tenant { @@ -196,6 +205,7 @@ mod tests { assert_eq!(val.administrators, Vec::::new()); assert_eq!(val.payment_status, TenantPaymentStatus::Delinquent); assert_eq!(val.token_account, Pubkey::default()); + assert_eq!(val.include_topologies, Vec::::new()); } #[test] @@ -213,6 +223,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let data = borsh::to_vec(&val).unwrap(); @@ -255,6 +266,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let err = val.validate(); assert!(err.is_err()); @@ -276,6 +288,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let err = val.validate(); assert!(err.is_err()); diff --git a/smartcontract/programs/doublezero-serviceability/src/state/topology.rs b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs new file mode 100644 index 0000000000..4db532c685 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/state/topology.rs @@ -0,0 +1,83 @@ +use crate::{error::Validate, state::accounttype::AccountType}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::pubkey::Pubkey; + +#[repr(u8)] +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, Copy, PartialEq, Default)] +#[borsh(use_discriminant = true)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum TopologyConstraint { + #[default] + IncludeAny = 0, + Exclude = 1, +} + +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TopologyInfo { + pub account_type: AccountType, + pub owner: Pubkey, + pub bump_seed: u8, + pub name: String, // max 32 bytes enforced on create + pub admin_group_bit: u8, // 0–127 + pub flex_algo_number: u8, // always 128 + admin_group_bit + pub constraint: TopologyConstraint, +} + +impl std::fmt::Display for TopologyInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "name={} bit={} algo={} color={} constraint={:?}", + self.name, + self.admin_group_bit, + self.flex_algo_number, + self.admin_group_bit as u16 + 1, + self.constraint + ) + } +} + +impl TryFrom<&[u8]> for TopologyInfo { + type Error = solana_program::program_error::ProgramError; + + fn try_from(mut data: &[u8]) -> Result { + Ok(Self { + account_type: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + owner: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + bump_seed: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + name: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + admin_group_bit: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + flex_algo_number: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + constraint: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), + }) + } +} + +impl TryFrom<&solana_program::account_info::AccountInfo<'_>> for TopologyInfo { + type Error = solana_program::program_error::ProgramError; + + fn try_from(account: &solana_program::account_info::AccountInfo) -> Result { + Self::try_from(&account.data.borrow()[..]) + } +} + +impl Validate for TopologyInfo { + fn validate(&self) -> Result<(), crate::error::DoubleZeroError> { + if self.account_type != AccountType::Topology { + return Err(crate::error::DoubleZeroError::InvalidAccountType); + } + if self.name.len() > 32 { + return Err(crate::error::DoubleZeroError::NameTooLong); + } + Ok(()) + } +} + +/// Flex-algo node segment entry on a Vpnv4 loopback Interface account. +#[derive(BorshSerialize, BorshDeserialize, Debug, Clone, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FlexAlgoNodeSegment { + pub topology: Pubkey, // TopologyInfo PDA pubkey + pub node_segment_idx: u16, // allocated from SegmentRoutingIds ResourceExtension +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs b/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs index fdffaa5bc6..f4d63858f8 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs @@ -63,6 +63,8 @@ async fn test_accesspass_allow_multiple_ip() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_accesspass_allow_multiple_ip() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs index cabec169ec..723bc655f1 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs @@ -86,13 +86,13 @@ struct CreateSubscribeFixture { async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFixture { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -109,6 +109,8 @@ async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFi let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state execute_transaction( @@ -148,6 +150,7 @@ async fn setup_create_subscribe_fixture(client_ip: [u8; 4]) -> CreateSubscribeFi AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1525,13 +1528,13 @@ async fn test_create_subscribe_user_foundation_owner_override() { let client_ip = [100, 0, 0, 30]; let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -1548,6 +1551,8 @@ async fn test_create_subscribe_user_foundation_owner_override() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state (payer is automatically in foundation_allowlist) execute_transaction( @@ -1587,6 +1592,7 @@ async fn test_create_subscribe_user_foundation_owner_override() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1858,6 +1864,7 @@ async fn test_create_subscribe_user_sentinel_owner_override() { program_id, processor!(process_instruction), ); + program_test.set_compute_max_units(1_000_000); // Fund the sentinel program_test.add_account( @@ -1888,6 +1895,8 @@ async fn test_create_subscribe_user_sentinel_owner_override() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state execute_transaction( @@ -1944,6 +1953,7 @@ async fn test_create_subscribe_user_sentinel_owner_override() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -2214,6 +2224,7 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { program_id, processor!(process_instruction), ); + program_test.set_compute_max_units(1_000_000); // Fund the non-foundation payer so it can sign transactions program_test.add_account( @@ -2244,6 +2255,8 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { let (multicast_publisher_block, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Init global state with foundation payer execute_transaction( @@ -2283,6 +2296,7 @@ async fn test_create_subscribe_user_non_foundation_owner_override_rejected() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs index 4b5cd52b5f..08baafaed7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/delete_cyoa_interface_test.rs @@ -104,6 +104,7 @@ async fn test_delete_cyoa_interface_with_invalid_sibling() { ip_net: "63.243.225.62/30".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; // Interface B: INVALID CYOA interface — CYOA set but ip_net is default (0.0.0.0/0). @@ -123,6 +124,7 @@ async fn test_delete_cyoa_interface_with_invalid_sibling() { ip_net: NetworkV4::default(), // <-- INVALID: CYOA without ip_net node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; let device = Device { @@ -337,6 +339,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { ip_net: "63.243.225.62/30".parse().unwrap(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; // Interface B: INVALID CYOA interface — CYOA set but ip_net is default. @@ -355,6 +358,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { ip_net: NetworkV4::default(), // <-- INVALID: CYOA without ip_net node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], }; let device = Device { @@ -410,7 +414,7 @@ async fn test_update_cyoa_interface_with_invalid_sibling() { program_id, DoubleZeroInstruction::UpdateDeviceInterface(DeviceInterfaceUpdateArgs { name: "ethernet1".to_string(), - mtu: Some(1500), + mtu: Some(1500), // CYOA interfaces require CYOA_DIA_INTERFACE_MTU = 1500 ..Default::default() }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs index b825865975..082b9f1bff 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs @@ -75,6 +75,8 @@ async fn test_device() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -98,6 +100,7 @@ async fn test_device() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1019,13 +1022,13 @@ async fn test_device_update_multicast_counts_ignored_for_non_foundation_payer() async fn setup_program_with_location_and_exchange( ) -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey, Pubkey, Pubkey) { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; // Start with a fresh program let (program_config_pubkey, _) = get_program_config_pda(&program_id); @@ -1058,6 +1061,8 @@ async fn setup_program_with_location_and_exchange( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1082,6 +1087,7 @@ async fn setup_program_with_location_and_exchange( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs index 133cc3d871..e9f8cb329d 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs @@ -21,13 +21,13 @@ use test_helpers::*; #[tokio::test] async fn device_update_location_test() { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; /***********************************************************************************************************************************/ println!("🟢 Start test_device"); @@ -64,6 +64,8 @@ async fn device_update_location_test() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -87,6 +89,7 @@ async fn device_update_location_test() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs index 55e237f29a..1c16f6c51c 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs @@ -57,6 +57,8 @@ async fn exchange_setdevice() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -80,6 +82,7 @@ async fn exchange_setdevice() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs index 7c5d1b576f..6a14393eb4 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs @@ -55,6 +55,8 @@ async fn test_exchange() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -79,6 +81,7 @@ async fn test_exchange() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -283,6 +286,8 @@ async fn test_exchange_delete_from_suspended() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -307,6 +312,7 @@ async fn test_exchange_delete_from_suspended() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -409,6 +415,8 @@ async fn test_exchange_owner_and_foundation_can_update_status() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -432,6 +440,7 @@ async fn test_exchange_owner_and_foundation_can_update_status() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -525,13 +534,13 @@ async fn test_exchange_owner_and_foundation_can_update_status() { #[tokio::test] async fn test_exchange_bgp_community_autoassignment() { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; println!("🟢 Start test_exchange_bgp_community_autoassignment"); @@ -550,6 +559,8 @@ async fn test_exchange_bgp_community_autoassignment() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); println!("Initializing global state..."); execute_transaction( @@ -589,6 +600,7 @@ async fn test_exchange_bgp_community_autoassignment() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -746,6 +758,7 @@ async fn test_exchange_bgp_community_autoassignment() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -857,6 +870,8 @@ async fn test_suspend_exchange_from_suspended_fails() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -896,6 +911,7 @@ async fn test_suspend_exchange_from_suspended_fails() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index 45516901d2..cf4afef973 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -74,6 +74,8 @@ async fn test_doublezero_program() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -98,6 +100,7 @@ async fn test_doublezero_program() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -693,6 +696,15 @@ async fn test_doublezero_program() { use_onchain_allocation: false, }; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -703,6 +715,7 @@ async fn test_doublezero_program() { AccountMeta::new(device_la_pubkey, false), AccountMeta::new(device_ny_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index dea1f51aa9..a898bf96d7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -70,6 +70,8 @@ async fn test_device_interfaces() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -93,6 +95,7 @@ async fn test_device_interfaces() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1476,6 +1479,8 @@ async fn test_interface_create_invalid_mtu_non_cyoa() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1500,6 +1505,7 @@ async fn test_interface_create_invalid_mtu_non_cyoa() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1668,6 +1674,8 @@ async fn test_interface_create_invalid_mtu_cyoa() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1692,6 +1700,7 @@ async fn test_interface_create_invalid_mtu_cyoa() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index 385e175443..6b0729371d 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -64,6 +64,8 @@ async fn test_dzx_link() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -88,6 +90,7 @@ async fn test_dzx_link() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -614,6 +617,15 @@ async fn test_dzx_link() { /*****************************************************************************************************************************************************/ println!("🟢 13. Activate Link..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + // Regression: activation must fail if side A/Z accounts do not match link.side_{a,z}_pk let res = try_execute_transaction( &mut banks_client, @@ -630,6 +642,7 @@ async fn test_dzx_link() { AccountMeta::new(device_z_pubkey, false), AccountMeta::new(device_a_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -652,6 +665,7 @@ async fn test_dzx_link() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs index 9ddc1709a4..f10d1f0576 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_onchain_allocation_test.rs @@ -382,6 +382,15 @@ async fn test_activate_link_with_onchain_allocation() { // Activate Link with onchain allocation println!("Activating Link with onchain allocation..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -398,6 +407,7 @@ async fn test_activate_link_with_onchain_allocation() { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_pda, false), AccountMeta::new(link_ids_pda, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -725,6 +735,15 @@ async fn test_activate_link_legacy_path() { let expected_tunnel_net: doublezero_program_common::types::NetworkV4 = "10.0.0.0/21".parse().unwrap(); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -739,6 +758,7 @@ async fn test_activate_link_legacy_path() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1036,6 +1056,16 @@ async fn test_closeaccount_link_with_deallocation() { get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); let (link_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::LinkIds); + // Create unicast-default topology (required for link activation) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate Link with onchain allocation execute_transaction( &mut banks_client, @@ -1053,6 +1083,7 @@ async fn test_closeaccount_link_with_deallocation() { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_pda, false), AccountMeta::new(link_ids_pda, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index e3fee82601..428d010a52 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -5,6 +5,7 @@ use doublezero_serviceability::{ contributor::create::ContributorCreateArgs, device::interface::{update::DeviceInterfaceUpdateArgs, DeviceInterfaceUnlinkArgs}, link::{activate::*, create::*, delete::*, sethealth::LinkSetHealthArgs, update::*}, + topology::create::TopologyCreateArgs, *, }, resource::ResourceType, @@ -16,6 +17,7 @@ use doublezero_serviceability::{ InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, LoopbackType, RoutingMode, }, link::*, + topology::TopologyConstraint, }, }; use globalconfig::set::SetGlobalConfigArgs; @@ -63,6 +65,8 @@ async fn test_wan_link() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +91,7 @@ async fn test_wan_link() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -556,6 +561,15 @@ async fn test_wan_link() { /*****************************************************************************************************************************************************/ println!("🟢 8. Activate Link..."); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -570,6 +584,7 @@ async fn test_wan_link() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -606,6 +621,8 @@ async fn test_wan_link() { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, + unicast_drained: None, }), vec![ AccountMeta::new(tunnel_pubkey, false), @@ -898,6 +915,8 @@ async fn test_wan_link_rejects_cyoa_interface() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -922,6 +941,7 @@ async fn test_wan_link_rejects_cyoa_interface() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1397,6 +1417,15 @@ async fn test_wan_link_rejects_cyoa_interface() { ) .await; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + // Attempt to activate the link - should fail because side A now has CYOA let res = try_execute_transaction( &mut banks_client, @@ -1412,6 +1441,7 @@ async fn test_wan_link_rejects_cyoa_interface() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1458,6 +1488,8 @@ async fn test_cannot_set_cyoa_on_linked_interface() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1482,6 +1514,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1764,6 +1797,15 @@ async fn test_cannot_set_cyoa_on_linked_interface() { ) .await; + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -1778,6 +1820,7 @@ async fn test_cannot_set_cyoa_on_linked_interface() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -1909,6 +1952,8 @@ async fn setup_link_env() -> ( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -1933,6 +1978,7 @@ async fn setup_link_env() -> ( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -2289,6 +2335,16 @@ async fn test_link_delete_from_soft_drained() { .await .expect("Failed to get blockhash"); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate execute_transaction( &mut banks_client, @@ -2304,6 +2360,7 @@ async fn test_link_delete_from_soft_drained() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2412,6 +2469,16 @@ async fn test_link_delete_from_hard_drained() { .await .expect("Failed to get blockhash"); + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + // Activate execute_transaction( &mut banks_client, @@ -2427,6 +2494,7 @@ async fn test_link_delete_from_hard_drained() { AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) @@ -2538,3 +2606,682 @@ async fn test_link_create_invalid_mtu() { error_string ); } + +#[tokio::test] +async fn test_link_activation_auto_tags_unicast_default() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + // Activate the link — it should auto-tag with UNICAST-DEFAULT + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Verify link_topologies was set to [unicast_default_pda] + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .expect("Link not found") + .get_tunnel() + .unwrap(); + assert_eq!(link.status, LinkStatus::Activated); + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link.link_topologies should be [unicast-default PDA] after activation" + ); +} + +#[tokio::test] +async fn test_link_activation_fails_without_unicast_default() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + // Derive the unicast-default PDA without creating it + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Attempt to activate — should fail because the unicast-default account is + // system-owned (not created), triggering the owner check before key validation. + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", + error_string + ); +} + +#[tokio::test] +async fn test_link_topology_cap_at_8_rejected() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Attempt to set 9 topology pubkeys — exceeds cap of 8 + let nine_pubkeys: Vec = (0..9).map(|_| Pubkey::new_unique()).collect(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(nine_pubkeys), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", + error_string + ); +} + +#[tokio::test] +async fn test_link_topology_invalid_account_rejected() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + _device_a_pubkey, + _device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(_device_a_pubkey, false), + AccountMeta::new(_device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + // Pass a bogus pubkey that has no onchain data — data_is_empty() → InvalidArgument + let bogus_pubkey = Pubkey::new_unique(); + let result = try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(vec![bogus_pubkey]), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(bogus_pubkey, false)], + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(65)"), + "Expected InvalidArgument error (Custom(65)), got: {}", + error_string + ); +} + +#[tokio::test] +async fn test_link_topology_valid_accepted() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create a second topology to assign to the link + let (topo_a_pda, _) = get_topology_pda(&program_id, "topo-a"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "topo-a".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topo_a_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Assign the topology to the link — should succeed + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + try_execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(vec![topo_a_pda]), + ..Default::default() + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(topo_a_pda, false)], + ) + .await + .expect("Setting valid topology on link should succeed"); +} + +// ─── link_topologies update tests ──────────────────────────────────────────── + +/// Foundation key can reassign link_topologies to a different topology after +/// activation, overriding the auto-tag set by ActivateLink. +#[tokio::test] +async fn test_link_topology_reassigned_by_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + _contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create unicast-default topology (required for activation) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + // Create a second topology: high-bandwidth + let (high_bandwidth_pda, _) = get_topology_pda(&program_id, "high-bandwidth"); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "high-bandwidth".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(high_bandwidth_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Activate — auto-tags with unicast-default + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.link_topologies, vec![unicast_default_pda]); + + // Foundation reassigns link_topologies to high-bandwidth + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction_with_extra_accounts( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![high_bandwidth_pda]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(_contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + &[AccountMeta::new_readonly(high_bandwidth_pda, false)], + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!( + link.link_topologies, + vec![high_bandwidth_pda], + "link_topologies should be updated to high-bandwidth PDA" + ); +} + +/// Foundation key can clear link_topologies to an empty vector, removing the +/// link from all constrained topologies (multicast-only link case). +#[tokio::test] +async fn test_link_topology_cleared_by_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + // Activate — auto-tags with unicast-default + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.link_topologies, vec![unicast_default_pda]); + + // Foundation clears link_topologies — link becomes multicast-only + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link = get_account_data(&mut banks_client, tunnel_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!( + link.link_topologies, + vec![], + "link_topologies should be empty after clearing" + ); +} + +/// A non-foundation payer cannot set link_topologies — the instruction must +/// be rejected with NotAllowed (Custom(8)). +#[tokio::test] +async fn test_link_topology_update_rejected_for_non_foundation() { + let ( + mut banks_client, + program_id, + payer, + globalstate_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + tunnel_pubkey, + ) = setup_link_env().await; + + let recent_blockhash = banks_client + .get_latest_blockhash() + .await + .expect("Failed to get blockhash"); + + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalstate_pubkey, + &payer, + ) + .await; + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/21".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + &payer, + ) + .await; + + // Create a non-foundation keypair and fund it + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 1_000_000_000, + ) + .await; + + // Non-foundation payer attempts to set link_topologies on the existing link. + // The outer ownership check fails because the payer is neither the + // contributor's owner nor in the foundation allowlist. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + code: None, + contributor_pk: None, + tunnel_type: None, + bandwidth: None, + mtu: None, + delay_ns: None, + jitter_ns: None, + delay_override_ns: None, + status: None, + desired_status: None, + tunnel_id: None, + tunnel_net: None, + use_onchain_allocation: false, + link_topologies: Some(vec![unicast_default_pda]), + unicast_drained: None, + }), + vec![ + AccountMeta::new(tunnel_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + let error_string = format!("{:?}", result.unwrap_err()); + assert!( + error_string.contains("Custom(8)"), + "Expected NotAllowed error (Custom(8)), got: {}", + error_string + ); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs index f686bb161f..d6fe8c2dfe 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs @@ -91,6 +91,8 @@ async fn setup_fixture() -> TestFixture { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -115,6 +117,7 @@ async fn setup_fixture() -> TestFixture { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs index c738acc935..425c3fc9f7 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/tenant_test.rs @@ -9,8 +9,14 @@ use doublezero_serviceability::{ resource::ResourceType, state::{accounttype::AccountType, tenant::*}, }; +use solana_program::instruction::InstructionError; use solana_program_test::*; -use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey}; +use solana_sdk::{ + instruction::AccountMeta, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::TransactionError, +}; mod test_helpers; use test_helpers::*; @@ -84,6 +90,7 @@ async fn test_tenant() { metro_routing: Some(false), route_liveness: Some(true), billing: None, + include_topologies: None, }), vec![ AccountMeta::new(tenant_pubkey, false), @@ -125,6 +132,7 @@ async fn test_tenant() { metro_routing: None, route_liveness: None, billing: Some(billing_config), + include_topologies: None, }), vec![ AccountMeta::new(tenant_pubkey, false), @@ -563,3 +571,276 @@ async fn test_tenant_remove_nonexistent_administrator_fails() { println!("✅ Nonexistent administrator removal correctly rejected"); println!("🟢🟢🟢 End test_tenant_remove_nonexistent_administrator_fails 🟢🟢🟢"); } + +#[tokio::test] +async fn test_tenant_include_topologies_defaults_to_empty() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-default"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + + assert_eq!(tenant.include_topologies, Vec::::new()); + + println!("✅ include_topologies defaults to empty on new Tenant"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_foundation_can_set() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-foundation"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // Foundation key (payer) sets include_topologies + let topology_pubkey = Pubkey::new_unique(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![topology_pubkey]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + + assert_eq!(tenant.include_topologies, vec![topology_pubkey]); + + println!("✅ Foundation key can set include_topologies"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_non_foundation_rejected() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-nonfoundation"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // A keypair not in the foundation allowlist + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![Pubkey::new_unique()]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::NotAllowed = Custom(8) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), + } + + println!("✅ Non-foundation key correctly rejected for include_topologies"); +} + +#[tokio::test] +async fn test_tenant_include_topologies_reset_to_empty() { + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + + let tenant_code = "incl-topo-reset"; + let (tenant_pubkey, _) = get_tenant_pda(&program_id, tenant_code); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTenant(TenantCreateArgs { + code: tenant_code.to_string(), + administrator: Pubkey::new_unique(), + token_account: None, + metro_routing: false, + route_liveness: false, + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(vrf_ids_pda, false), + ], + &payer, + ) + .await; + + // Set include_topologies to a non-empty list + let topology_pubkey = Pubkey::new_unique(); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![topology_pubkey]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + assert_eq!(tenant.include_topologies, vec![topology_pubkey]); + + // Now reset to empty + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateTenant(TenantUpdateArgs { + vrf_id: None, + token_account: None, + metro_routing: None, + route_liveness: None, + billing: None, + include_topologies: Some(vec![]), + }), + vec![ + AccountMeta::new(tenant_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let tenant = get_account_data(&mut banks_client, tenant_pubkey) + .await + .expect("Unable to get Tenant") + .get_tenant() + .unwrap(); + assert_eq!(tenant.include_topologies, Vec::::new()); + + println!("✅ include_topologies can be reset to empty"); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index 307179077c..e574ea21f3 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -4,13 +4,14 @@ use doublezero_serviceability::{ instructions::*, pda::{ get_globalconfig_pda, get_globalstate_pda, get_program_config_pda, - get_resource_extension_pda, + get_resource_extension_pda, get_topology_pda, }, - processors::globalconfig::set::SetGlobalConfigArgs, + processors::{globalconfig::set::SetGlobalConfigArgs, topology::create::TopologyCreateArgs}, resource::ResourceType, state::{ accountdata::AccountData, accounttype::AccountType, device::Device, globalstate::GlobalState, resource_extension::ResourceExtensionOwned, + topology::TopologyConstraint, }, }; use solana_program_test::*; @@ -478,13 +479,15 @@ pub async fn wait_for_new_blockhash(banks_client: &mut BanksClient) -> solana_pr pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey) { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + // SetGlobalConfig creates multiple ResourceExtension accounts; raise the budget so this + // doesn't flake under load when many test processes run concurrently. + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -501,6 +504,8 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -540,6 +545,7 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -553,3 +559,41 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, globalconfig_pubkey, ) } + +/// Create the "unicast-default" topology. +/// Returns the PDA of the "unicast-default" topology. +/// Requires that global state + global config are already initialized (AdminGroupBits is +/// created by SetGlobalConfig). +#[allow(dead_code)] +pub async fn create_unicast_default_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + _globalconfig_pubkey: Pubkey, + payer: &Keypair, +) -> Pubkey { + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(unicast_default_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; + + unicast_default_pda +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs new file mode 100644 index 0000000000..316312e77a --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/topology_test.rs @@ -0,0 +1,2415 @@ +//! Tests for TopologyInfo and FlexAlgoNodeSegment (RFC-18 / Link Classification). + +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{ + get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, + get_location_pda, get_resource_extension_pda, get_topology_pda, + }, + processors::{ + contributor::create::ContributorCreateArgs, + device::{ + activate::DeviceActivateArgs, + create::DeviceCreateArgs, + interface::{ + activate::DeviceInterfaceActivateArgs, create::DeviceInterfaceCreateArgs, + unlink::DeviceInterfaceUnlinkArgs, + }, + }, + exchange::create::ExchangeCreateArgs, + link::{activate::LinkActivateArgs, create::LinkCreateArgs, update::LinkUpdateArgs}, + location::create::LocationCreateArgs, + topology::{ + backfill::TopologyBackfillArgs, clear::TopologyClearArgs, create::TopologyCreateArgs, + delete::TopologyDeleteArgs, + }, + }, + resource::ResourceType, + state::{ + accounttype::AccountType, + device::{DeviceDesiredStatus, DeviceType}, + interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, + link::{Link, LinkDesiredStatus, LinkLinkType}, + topology::{TopologyConstraint, TopologyInfo}, + }, +}; +use solana_program::instruction::InstructionError; +use solana_program_test::*; +use solana_sdk::{ + instruction::AccountMeta, pubkey::Pubkey, signature::Keypair, signer::Signer, + transaction::TransactionError, +}; + +mod test_helpers; +use test_helpers::*; + +/// Helper that creates the topology using the standard account layout. +async fn create_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + admin_group_bits_pda: Pubkey, + name: &str, + constraint: TopologyConstraint, + payer: &Keypair, +) -> Pubkey { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: name.to_string(), + constraint, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + ) + .await; + topology_pda +} + +async fn get_topology(banks_client: &mut BanksClient, pubkey: Pubkey) -> TopologyInfo { + get_account_data(banks_client, pubkey) + .await + .expect("Topology account should exist") + .get_topology() + .expect("Account should be a Topology") +} + +#[tokio::test] +async fn test_admin_group_bits_create_and_pre_mark() { + println!("[TEST] test_admin_group_bits_create_and_pre_mark"); + + // AdminGroupBits is created automatically by SetGlobalConfig (via setup_program_with_globalconfig). + let (mut banks_client, _payer, program_id, _globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (resource_pubkey, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Verify the account was created and has data + let account = banks_client + .get_account(resource_pubkey) + .await + .unwrap() + .expect("AdminGroupBits account should exist"); + + assert!( + !account.data.is_empty(), + "AdminGroupBits account should have non-empty data" + ); + + // Bit 0 is implicitly reserved for UNICAST-DRAINED via IdRange(1, 127). + // No bits are pre-marked at resource creation time. + let resource = get_resource_extension_data(&mut banks_client, resource_pubkey) + .await + .expect("AdminGroupBits resource extension should be deserializable"); + + let allocated = resource.iter_allocated(); + assert_eq!( + allocated.len(), + 0, + "no bits should be pre-marked at creation" + ); + + println!("[PASS] test_admin_group_bits_create_and_pre_mark"); +} + +#[test] +fn test_topology_info_roundtrip() { + use doublezero_serviceability::state::{ + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + + let info = TopologyInfo { + account_type: AccountType::Topology, + owner: solana_sdk::pubkey::Pubkey::new_unique(), + bump_seed: 42, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + let bytes = borsh::to_vec(&info).unwrap(); + let decoded = TopologyInfo::try_from(bytes.as_slice()).unwrap(); + assert_eq!(decoded, info); +} + +#[test] +fn test_flex_algo_node_segment_roundtrip() { + use doublezero_serviceability::state::topology::FlexAlgoNodeSegment; + + let seg = FlexAlgoNodeSegment { + topology: solana_sdk::pubkey::Pubkey::new_unique(), + node_segment_idx: 1001, + }; + let bytes = borsh::to_vec(&seg).unwrap(); + let decoded: FlexAlgoNodeSegment = borsh::from_slice(&bytes).unwrap(); + assert_eq!(decoded.node_segment_idx, 1001); +} + +// ============================================================================ +// Integration tests for TopologyCreate instruction +// ============================================================================ + +#[tokio::test] +async fn test_topology_create_bit_1_first() { + println!("[TEST] test_topology_create_bit_1_first"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology = get_topology(&mut banks_client, topology_pda).await; + + assert_eq!(topology.account_type, AccountType::Topology); + assert_eq!(topology.name, "unicast-default"); + // Bit 0 is reserved for UNICAST-DRAINED (implicit via IdRange(1, 127)), + // so the first user topology gets bit 1. + assert_eq!(topology.admin_group_bit, 1); + assert_eq!(topology.flex_algo_number, 129); + assert_eq!(topology.constraint, TopologyConstraint::IncludeAny); + + println!("[PASS] test_topology_create_bit_1_first"); +} + +#[tokio::test] +async fn test_topology_create_consecutive_bits() { + println!("[TEST] test_topology_create_consecutive_bits"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // First topology gets bit 1 (bit 0 is implicitly reserved for UNICAST-DRAINED) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Second topology gets the next consecutive bit (2) + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "shelby", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology = get_topology(&mut banks_client, topology_pda).await; + + assert_eq!(topology.name, "shelby"); + assert_eq!(topology.admin_group_bit, 2); + assert_eq!(topology.flex_algo_number, 130); + + println!("[PASS] test_topology_create_consecutive_bits"); +} + +#[tokio::test] +async fn test_topology_create_non_foundation_rejected() { + println!("[TEST] test_topology_create_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let (topology_pda, _) = get_topology_pda(&program_id, "unauthorized-topology"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unauthorized-topology".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_create_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_create_name_too_long_rejected() { + println!("[TEST] test_topology_create_name_too_long_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // 33-char name exceeds MAX_TOPOLOGY_NAME_LEN=32 + // We use a dummy pubkey for the topology PDA since the name validation fires + // before the PDA check, and find_program_address panics on seeds > 32 bytes. + let long_name = "a".repeat(33); + let topology_pda = Pubkey::new_unique(); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: long_name, + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // DoubleZeroError::InvalidArgument = Custom(65) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(65), + ))) => {} + _ => panic!( + "Expected InvalidArgument error (Custom(65)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_create_name_too_long_rejected"); +} + +#[tokio::test] +async fn test_topology_create_duplicate_rejected() { + println!("[TEST] test_topology_create_duplicate_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // First creation succeeds + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Second creation of same name must fail. + // Wait for a new blockhash to avoid transaction deduplication in the test environment. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // ProgramError::AccountAlreadyInitialized maps to InstructionError::AccountAlreadyInitialized + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::AccountAlreadyInitialized, + ))) => {} + _ => panic!("Expected AccountAlreadyInitialized error, got {:?}", result), + } + + println!("[PASS] test_topology_create_duplicate_rejected"); +} + +#[tokio::test] +async fn test_topology_create_backfills_vpnv4_loopbacks() { + println!("[TEST] test_topology_create_backfills_vpnv4_loopbacks"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Create AdminGroupBits and SegmentRoutingIds resources + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Set up a full device with a Vpnv4 loopback interface + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface (without onchain allocation — backfill assigns the segment) + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "loopback0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Create topology passing the Device + SegmentRoutingIds as remaining accounts + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let instruction = DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }); + let base_accounts = vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(segment_routing_ids_pda, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &instruction, + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: the Vpnv4 loopback now has a flex_algo_node_segment pointing to the topology + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after backfill" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the newly created topology" + ); + + // Step 8: Call TopologyCreate again with same device — idempotent, no duplicate segment + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + // Create a second topology so we get a different PDA but still exercise idempotency + // by passing the device again — the first topology's segment must not be duplicated. + // Instead, verify idempotency by calling CreateTopology with the same device a second time + // using a different topology name, then checking the device has exactly two segments (not three). + let (topology2_pda, _) = get_topology_pda(&program_id, "unicast-secondary"); + let instruction2 = DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-secondary".to_string(), + constraint: TopologyConstraint::IncludeAny, + }); + let base_accounts2 = vec![ + AccountMeta::new(topology2_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let mut tx2 = create_transaction_with_extra_accounts( + program_id, + &instruction2, + &base_accounts2, + &payer, + &extra_accounts, + ); + tx2.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx2).await.unwrap(); + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after second topology"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 2, + "Expected two segments after second topology backfill (one per topology)" + ); + + // Step 9: Idempotency — call CreateTopology for unicast-secondary again with the same device. + // The segment for unicast-secondary must not be duplicated. + let _recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + // We need a new topology PDA since unicast-secondary already exists; + // instead use unicast-secondary's PDA but re-create a third topology and pass the device twice. + // Actually the simplest idempotency check: use a third unique topology but re-pass the device — + // after the call, the device should have exactly 3 segments (not more). + // The real idempotency guard is: if we pass a device that already has a segment for topology X, + // a second CreateTopology for X with that device does not add another. We test this by + // calling CreateTopology for topology2 again (which would fail because account already initialized), + // but instead we verify directly: re-run step 8 with the same topology2 already existing — + // the transaction should fail with AccountAlreadyInitialized before the backfill runs. + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx_idem = create_transaction_with_extra_accounts( + program_id, + &instruction2, + &base_accounts2, + &payer, + &extra_accounts, + ); + tx_idem.try_sign(&[&payer], recent_blockhash).unwrap(); + let idem_result = banks_client.process_transaction(tx_idem).await; + match idem_result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::AccountAlreadyInitialized, + ))) => {} + _ => panic!( + "Expected AccountAlreadyInitialized on duplicate create, got {:?}", + idem_result + ), + } + + println!("[PASS] test_topology_create_backfills_vpnv4_loopbacks"); +} + +// ============================================================================ +// Helpers for delete/clear tests +// ============================================================================ + +/// Creates a delete topology instruction. +async fn delete_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + extra_link_accounts: Vec, + payer: &Keypair, +) -> Result<(), BanksClientError> { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let base_accounts = vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts: Vec = extra_link_accounts; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: name.to_string(), + }), + &base_accounts, + payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await +} + +/// Creates a clear topology instruction, passing the given link accounts as writable. +async fn clear_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + name: &str, + link_accounts: Vec, + payer: &Keypair, +) { + let (topology_pda, _) = get_topology_pda(&program_id, name); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: name.to_string(), + }), + &base_accounts, + payer, + &link_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); +} + +/// Gets a Link account (panics if not found or not deserializable). +async fn get_link(banks_client: &mut BanksClient, pubkey: Pubkey) -> Link { + let account = banks_client + .get_account(pubkey) + .await + .unwrap() + .expect("Link account should exist"); + Link::try_from(&account.data[..]).expect("Should deserialize as Link") +} + +/// Sets up a minimal WAN link (two devices, contributor, location, exchange, one link). +/// Returns (link_pubkey, contributor_pubkey, device_a_pubkey, device_z_pubkey). +#[allow(clippy::too_many_arguments)] +async fn setup_wan_link( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + payer: &Keypair, +) -> (Pubkey, Pubkey, Pubkey, Pubkey) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Location + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Exchange + let (globalconfig_pubkey, _) = get_globalconfig_pda(&program_id); + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Contributor + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device A + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (device_a_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dza".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device A interface + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Ethernet0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + ip_net: None, + cir: 0, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Ethernet0".to_string(), + ip_net: "10.0.0.0/31".parse().unwrap(), + node_segment_idx: 0, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device Z + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (device_z_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dzb".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [9, 9, 9, 9].into(), + dz_prefixes: "111.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device Z interface + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Ethernet1".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + ip_net: None, + cir: 0, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Ethernet1".to_string(), + ip_net: "10.0.0.1/31".parse().unwrap(), + node_segment_idx: 0, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Unlink interfaces (make them available for linking) + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet0".to_string(), + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet1".to_string(), + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Create link + let globalstate_account = get_globalstate(banks_client, globalstate_pubkey).await; + let (link_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLink(LinkCreateArgs { + code: "dza-dzb".to_string(), + link_type: LinkLinkType::WAN, + bandwidth: 20_000_000_000, + mtu: 9000, + delay_ns: 1_000_000, + jitter_ns: 100_000, + side_a_iface_name: "Ethernet0".to_string(), + side_z_iface_name: Some("Ethernet1".to_string()), + desired_status: Some(LinkDesiredStatus::Activated), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Activate link (unicast-default topology must already exist at this point) + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.100.0.0/30".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), + ], + payer, + ) + .await; + + ( + link_pubkey, + contributor_pubkey, + device_a_pubkey, + device_z_pubkey, + ) +} + +/// Assigns link_topologies on a link via LinkUpdate (foundation-only). +async fn assign_link_topology( + banks_client: &mut BanksClient, + program_id: Pubkey, + globalstate_pubkey: Pubkey, + link_pubkey: Pubkey, + contributor_pubkey: Pubkey, + topology_pubkeys: Vec, + payer: &Keypair, +) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let extra_accounts: Vec = topology_pubkeys + .iter() + .map(|pk| AccountMeta::new_readonly(*pk, false)) + .collect(); + execute_transaction_with_extra_accounts( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + link_topologies: Some(topology_pubkeys), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + payer, + &extra_accounts, + ) + .await; +} + +// ============================================================================ +// TopologyDelete tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_delete_succeeds_when_no_links() { + println!("[TEST] test_topology_delete_succeeds_when_no_links"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Verify it exists + let topology = get_topology(&mut banks_client, topology_pda).await; + assert_eq!(topology.name, "test-topology"); + + // Delete it with no link accounts + delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![], + &payer, + ) + .await + .expect("Delete should succeed with no referencing links"); + + // Verify account data is zeroed (closed) + let account = banks_client.get_account(topology_pda).await.unwrap(); + assert!( + account.is_none() || account.unwrap().data.is_empty(), + "Topology account should be closed after delete" + ); + + println!("[PASS] test_topology_delete_succeeds_when_no_links"); +} + +#[tokio::test] +async fn test_topology_delete_fails_when_link_references_it() { + println!("[TEST] test_topology_delete_fails_when_link_references_it"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link and assign the topology to it + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + // Verify the link references the topology + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.contains(&topology_pda)); + + // Attempt to delete — should fail because the link still references it + let result = delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new_readonly(link_pubkey, false)], + &payer, + ) + .await; + + // DoubleZeroError::ReferenceCountNotZero = Custom(13) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(13), + ))) => {} + _ => panic!( + "Expected ReferenceCountNotZero error (Custom(13)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_delete_fails_when_link_references_it"); +} + +#[tokio::test] +async fn test_topology_delete_bit_not_reused() { + println!("[TEST] test_topology_delete_bit_not_reused"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create "topology-a" — gets bit 1 (first available since bit 0 is reserved for UNICAST-DRAINED) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "topology-a", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Delete "topology-a" + delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "topology-a", + vec![], + &payer, + ) + .await + .expect("Delete should succeed"); + + // Create "topology-b" — must NOT get bit 1 (permanently marked even after delete), + // so it should get bit 2 + let topology_b_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "topology-b", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let topology_b = get_topology(&mut banks_client, topology_b_pda).await; + assert_eq!( + topology_b.admin_group_bit, 2, + "topology-b should get bit 2 (bit 1 permanently marked even after delete)" + ); + + println!("[PASS] test_topology_delete_bit_not_reused"); +} + +// ============================================================================ +// TopologyClear tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_clear_removes_from_links() { + println!("[TEST] test_topology_clear_removes_from_links"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Set up a WAN link and assign the topology to it + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + // Verify assignment + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.link_topologies.contains(&topology_pda)); + + // Clear topology from the link + clear_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new(link_pubkey, false)], + &payer, + ) + .await; + + // Verify the link no longer references the topology + let link = get_link(&mut banks_client, link_pubkey).await; + assert!( + !link.link_topologies.contains(&topology_pda), + "link_topologies should be empty after clear" + ); + assert!(link.link_topologies.is_empty()); + + println!("[PASS] test_topology_clear_removes_from_links"); +} + +#[tokio::test] +async fn test_topology_clear_is_idempotent() { + println!("[TEST] test_topology_clear_is_idempotent"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let test_topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Set up a WAN link but do NOT assign the "test-topology" topology + let (link_pubkey, _, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Verify link has only the unicast-default topology (auto-tagged at activation), + // NOT the "test-topology" topology + let link = get_link(&mut banks_client, link_pubkey).await; + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link_topologies should only contain unicast-default after activation" + ); + assert!( + !link.link_topologies.contains(&test_topology_pda), + "link_topologies should not contain test-topology" + ); + + // Call clear — link does not reference "test-topology", so nothing should change, no error + clear_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "test-topology", + vec![AccountMeta::new(link_pubkey, false)], + &payer, + ) + .await; + + // Verify link_topologies is unchanged (still only unicast-default) + let link = get_link(&mut banks_client, link_pubkey).await; + assert_eq!( + link.link_topologies, + vec![unicast_default_pda], + "link_topologies should still only contain unicast-default after no-op clear" + ); + + println!("[PASS] test_topology_clear_is_idempotent"); +} + +#[tokio::test] +async fn test_topology_delete_non_foundation_rejected() { + println!("[TEST] test_topology_delete_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create topology with foundation payer + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let result = delete_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + "unicast-default", + vec![], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_delete_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_clear_non_foundation_rejected() { + println!("[TEST] test_topology_clear_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + // Create topology with foundation payer + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Use a keypair that is NOT in the foundation allowlist + let non_foundation = Keypair::new(); + // Fund the non-foundation keypair so it can sign transactions + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + // Attempt ClearTopology with non-foundation payer + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "unicast-default".to_string(), + }), + vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_clear_non_foundation_rejected"); +} + +// ============================================================================ +// BackfillTopology tests +// ============================================================================ + +#[tokio::test] +async fn test_topology_backfill_populates_vpnv4_loopbacks() { + println!("[TEST] test_topology_backfill_populates_vpnv4_loopbacks"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "loopback0".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Create topology WITHOUT passing device accounts — no backfill at create time + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + // Verify: device has 0 flex_algo_node_segments before backfill + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 0, + "Expected no segments before BackfillTopology" + ); + + // Step 8: Call BackfillTopology instruction + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![AccountMeta::new(device_pubkey, false)]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: loopback now has 1 segment pointing to the topology + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after BackfillTopology" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the backfilled topology" + ); + + // Step 9: Call BackfillTopology again — idempotent, no duplicate segment + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let mut tx2 = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx2.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx2).await.unwrap(); + + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after second backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Idempotent: BackfillTopology must not add a duplicate segment" + ); + + println!("[PASS] test_topology_backfill_populates_vpnv4_loopbacks"); +} + +#[tokio::test] +async fn test_topology_backfill_non_foundation_rejected() { + println!("[TEST] test_topology_backfill_non_foundation_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + let (topology_pda, _) = get_topology_pda(&program_id, "unicast-default"); + + let non_foundation = Keypair::new(); + transfer( + &mut banks_client, + &payer, + &non_foundation.pubkey(), + 10_000_000, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &non_foundation, + ) + .await; + + // DoubleZeroError::Unauthorized = Custom(22) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(22), + ))) => {} + _ => panic!("Expected Unauthorized error (Custom(22)), got {:?}", result), + } + + println!("[PASS] test_topology_backfill_non_foundation_rejected"); +} + +#[tokio::test] +async fn test_topology_backfill_nonexistent_topology_rejected() { + println!("[TEST] test_topology_backfill_nonexistent_topology_rejected"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Use a topology PDA that has never been created + let (nonexistent_topology_pda, _) = get_topology_pda(&program_id, "does-not-exist"); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "does-not-exist".to_string(), + }), + vec![ + AccountMeta::new_readonly(nonexistent_topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // DoubleZeroError::InvalidArgument = Custom(65) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(65), + ))) => {} + _ => panic!( + "Expected InvalidArgument error (Custom(65)), got {:?}", + result + ), + } + + println!("[PASS] test_topology_backfill_nonexistent_topology_rejected"); +} + +#[tokio::test] +async fn test_topology_backfill_avoids_collision_with_existing_node_segment_idx() { + // Regression test: BackfillTopology must not re-use the base node_segment_idx + // when the on-chain SegmentRoutingIds resource was never updated by the activator + // (use_onchain_allocation=false path). Before the fix, backfill would allocate + // ID 1 for the flex-algo segment even though ID 1 was already used as the base + // node_segment_idx on the loopback. + println!("[TEST] test_topology_backfill_avoids_collision_with_existing_node_segment_idx"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + + // Step 1: Create Location + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(LocationCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + country: "us".to_string(), + lat: 1.234, + lng: 4.567, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 2: Create Exchange + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(ExchangeCreateArgs { + code: "la".to_string(), + name: "Los Angeles".to_string(), + lat: 1.234, + lng: 4.567, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 3: Create Contributor + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "cont".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 4: Create Device + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + let (tunnel_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_pubkey, 0)); + let (dz_prefix_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_pubkey, 0)); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "dz1".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [8, 8, 8, 8].into(), + dz_prefixes: "110.1.0.0/23".parse().unwrap(), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: Some(DeviceDesiredStatus::Activated), + resource_count: 0, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(location_pubkey, false), + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 5: Activate Device + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(globalconfig_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + // Step 6: Create a Vpnv4 loopback interface (without onchain allocation) + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDeviceInterface(DeviceInterfaceCreateArgs { + name: "Loopback255".to_string(), + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::Vpnv4, + interface_cyoa: InterfaceCYOA::None, + bandwidth: 0, + cir: 0, + ip_net: None, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + user_tunnel_endpoint: false, + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Step 7: Activate the loopback with explicit node_segment_idx=1, WITHOUT providing + // the SegmentRoutingIds account. This simulates the activator's use_onchain_allocation=false + // path: the base SR ID is set to 1 but the on-chain resource is never updated, so the + // resource still believes ID 1 is free. + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDeviceInterface(DeviceInterfaceActivateArgs { + name: "Loopback255".to_string(), + ip_net: "172.16.0.1/32".parse().unwrap(), + node_segment_idx: 1, + }), + // Only device + globalstate — no link_ips or segment_routing_ids accounts. + // This causes the processor to take the else branch and store node_segment_idx + // directly without updating the on-chain resource (accounts.len() == 4). + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Verify base state: loopback has node_segment_idx=1, no flex-algo segments yet. + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.node_segment_idx, 1, + "Base node_segment_idx should be 1" + ); + assert_eq!( + iface.flex_algo_node_segments.len(), + 0, + "No flex-algo segments before backfill" + ); + + // Step 8: Create topology + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Step 9: Call BackfillTopology. With the fix, the pre-mark pass marks ID 1 as used + // before allocating, so the flex-algo segment receives ID 2 (not 1). + let recent_blockhash = wait_for_new_blockhash(&mut banks_client).await; + let base_accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]; + let extra_accounts = vec![AccountMeta::new(device_pubkey, false)]; + let mut tx = create_transaction_with_extra_accounts( + program_id, + &DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + &base_accounts, + &payer, + &extra_accounts, + ); + tx.try_sign(&[&payer], recent_blockhash).unwrap(); + banks_client.process_transaction(tx).await.unwrap(); + + // Verify: flex-algo segment has node_segment_idx=2, NOT 1 (which is the base idx). + let device = get_device(&mut banks_client, device_pubkey) + .await + .expect("Device not found after backfill"); + let iface = device.interfaces[0].into_current_version(); + assert_eq!( + iface.node_segment_idx, 1, + "Base node_segment_idx must remain 1" + ); + assert_eq!( + iface.flex_algo_node_segments.len(), + 1, + "Expected one flex_algo_node_segment after backfill" + ); + assert_eq!( + iface.flex_algo_node_segments[0].topology, topology_pda, + "Segment should point to the backfilled topology" + ); + assert_eq!( + iface.flex_algo_node_segments[0].node_segment_idx, 2, + "flex-algo node_segment_idx must be 2 (fresh allocation), not 1 (base)" + ); + + println!("[PASS] test_topology_backfill_avoids_collision_with_existing_node_segment_idx"); +} + +// ============================================================================ +// unicast_drained tests +// ============================================================================ + +#[tokio::test] +async fn test_link_unicast_drained_contributor_can_set_own_link() { + println!("[TEST] test_link_unicast_drained_contributor_can_set_own_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Verify unicast_drained starts as false + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(!link.is_unicast_drained()); + + // Contributor A (payer) sets unicast_drained = true + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Read back: unicast_drained must be true + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.is_unicast_drained()); + + println!("[PASS] test_link_unicast_drained_contributor_can_set_own_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_contributor_cannot_set_other_link() { + println!("[TEST] test_link_unicast_drained_contributor_cannot_set_other_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + // Create the link owned by payer (contributor A) + let (link_pubkey, _contributor_a_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Create a second contributor owned by a different keypair (bad_actor) + let bad_actor = Keypair::new(); + transfer(&mut banks_client, &payer, &bad_actor.pubkey(), 10_000_000).await; + + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (contributor_b_pubkey, _) = + get_contributor_pda(&program_id, globalstate_account.account_index + 1); + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + // Foundation (payer) creates contributor B, owned by bad_actor + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "bad".to_string(), + }), + vec![ + AccountMeta::new(contributor_b_pubkey, false), + AccountMeta::new(bad_actor.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // bad_actor tries to set unicast_drained on contributor A's link using contributor B + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = execute_transaction_expect_failure( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_b_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &bad_actor, + ) + .await; + + // DoubleZeroError::NotAllowed = Custom(8) + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed error (Custom(8)), got {:?}", result), + } + + println!("[PASS] test_link_unicast_drained_contributor_cannot_set_other_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_foundation_can_set_any_link() { + println!("[TEST] test_link_unicast_drained_foundation_can_set_any_link"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + globalconfig_pubkey, + &payer, + ) + .await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // payer is in the foundation allowlist; it sets unicast_drained on a contributor's link + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link = get_link(&mut banks_client, link_pubkey).await; + assert!(link.is_unicast_drained()); + + println!("[PASS] test_link_unicast_drained_foundation_can_set_any_link"); +} + +#[tokio::test] +async fn test_link_unicast_drained_orthogonal_to_status_and_topologies() { + println!("[TEST] test_link_unicast_drained_orthogonal_to_status_and_topologies"); + + let (mut banks_client, payer, program_id, globalstate_pubkey, _globalconfig_pubkey) = + setup_program_with_globalconfig().await; + + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); + + let topology_pda = create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "test-topology", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + // Create unicast-default topology (required for link activation) + create_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + admin_group_bits_pda, + "unicast-default", + TopologyConstraint::IncludeAny, + &payer, + ) + .await; + + let (link_pubkey, contributor_pubkey, _, _) = + setup_wan_link(&mut banks_client, program_id, globalstate_pubkey, &payer).await; + + // Assign a topology to the link (foundation-only), replacing the unicast-default auto-tag + assign_link_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + link_pubkey, + contributor_pubkey, + vec![topology_pda], + &payer, + ) + .await; + + let link_before = get_link(&mut banks_client, link_pubkey).await; + assert!(link_before.link_topologies.contains(&topology_pda)); + assert!(!link_before.is_unicast_drained()); + + // Set unicast_drained = true + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UpdateLink(LinkUpdateArgs { + unicast_drained: Some(true), + ..Default::default() + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let link_after = get_link(&mut banks_client, link_pubkey).await; + assert!( + link_after.is_unicast_drained(), + "unicast_drained should be true" + ); + assert_eq!( + link_after.status, link_before.status, + "status should be unchanged" + ); + assert_eq!( + link_after.link_topologies, link_before.link_topologies, + "link_topologies should be unchanged" + ); + + println!("[PASS] test_link_unicast_drained_orthogonal_to_status_and_topologies"); +} diff --git a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs index 9027fcd6c2..cf6646f746 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs @@ -342,6 +342,15 @@ async fn setup_two_devices_with_link() -> ( .await; // Activate the link (interfaces become Activated with tunnel IPs) + let unicast_default_pda = create_unicast_default_topology( + &mut banks_client, + program_id, + globalstate_pubkey, + config_pubkey, + &payer, + ) + .await; + execute_transaction( &mut banks_client, recent_blockhash, @@ -356,6 +365,7 @@ async fn setup_two_devices_with_link() -> ( AccountMeta::new(device_a_pubkey, false), AccountMeta::new(device_z_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs index b265b91ad5..2669f46239 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs @@ -59,6 +59,8 @@ async fn test_user_migration() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -83,6 +85,7 @@ async fn test_user_migration() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs index d2bd69ca1d..60ee4a144a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs @@ -63,6 +63,8 @@ async fn test_old_user() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -87,6 +89,7 @@ async fn test_old_user() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs index 2bd2446220..27fe52160a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs @@ -85,13 +85,13 @@ async fn setup_user_onchain_allocation_test( // (user_tunnel_block is immutable once set, so we can't override it later) let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -108,6 +108,8 @@ async fn setup_user_onchain_allocation_test( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -147,6 +149,7 @@ async fn setup_user_onchain_allocation_test( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1946,13 +1949,13 @@ async fn setup_user_infra_without_user( ) { let program_id = Pubkey::new_unique(); - let (mut banks_client, payer, recent_blockhash) = ProgramTest::new( + let mut program_test = ProgramTest::new( "doublezero_serviceability", program_id, processor!(process_instruction), - ) - .start() - .await; + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; let (program_config_pubkey, _) = get_program_config_pda(&program_id); let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); @@ -1969,6 +1972,8 @@ async fn setup_user_infra_without_user( let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); // Initialize global state execute_transaction( @@ -2008,6 +2013,7 @@ async fn setup_user_infra_without_user( AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs index 6997f56f22..75debb3453 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs @@ -66,6 +66,8 @@ async fn test_user() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, @@ -90,6 +92,7 @@ async fn test_user() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -564,6 +567,8 @@ async fn test_user_ban_requires_pendingban() { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -587,6 +592,7 @@ async fn test_user_ban_requires_pendingban() { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) @@ -1216,6 +1222,8 @@ async fn setup_activated_user() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_transaction( &mut banks_client, recent_blockhash, @@ -1239,6 +1247,7 @@ async fn setup_activated_user() -> (BanksClient, Keypair, Pubkey, Pubkey, Pubkey AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], &payer, ) diff --git a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs index 66df5fe93f..2a6c6cfb2a 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/initialize_device_latency_samples_tests.rs @@ -710,6 +710,8 @@ async fn test_initialize_device_latency_samples_fail_link_wrong_owner() { side_z_iface_name: "Ethernet1".to_string(), link_health: LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: Vec::new(), + link_flags: 0, }; let mut data = Vec::new(); diff --git a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs index 96fdb07873..16674379f3 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs @@ -5,7 +5,7 @@ use doublezero_serviceability::{ pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_globalstate_pda, get_link_pda, get_location_pda, get_program_config_pda, - get_resource_extension_pda, + get_resource_extension_pda, get_topology_pda, }, processors::{ contributor::create::ContributorCreateArgs, @@ -23,6 +23,7 @@ use doublezero_serviceability::{ update::LinkUpdateArgs, }, location::{create::LocationCreateArgs, suspend::LocationSuspendArgs}, + topology::create::TopologyCreateArgs, }, resource::ResourceType, state::{ @@ -32,6 +33,7 @@ use doublezero_serviceability::{ interface::{InterfaceCYOA, InterfaceDIA, LoopbackType, RoutingMode}, link::{Link, LinkDesiredStatus, LinkHealth, LinkLinkType}, location::Location, + topology::TopologyConstraint, }, }; use doublezero_telemetry::{ @@ -740,6 +742,7 @@ pub struct ServiceabilityProgramHelper { pub global_state_pubkey: Pubkey, pub global_config_pubkey: Pubkey, + pub unicast_default_topology_pubkey: Pubkey, } impl ServiceabilityProgramHelper { @@ -747,7 +750,7 @@ impl ServiceabilityProgramHelper { context: Arc>, program_id: Pubkey, ) -> Result { - let (global_state_pubkey, global_config_pubkey) = { + let (global_state_pubkey, global_config_pubkey, unicast_default_topology_pubkey) = { let (mut banks_client, payer, recent_blockhash) = { let context = context.lock().unwrap(); ( @@ -787,6 +790,8 @@ impl ServiceabilityProgramHelper { let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::MulticastPublisherBlock); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::AdminGroupBits); execute_serviceability_instruction( &mut banks_client, &payer, @@ -811,11 +816,38 @@ impl ServiceabilityProgramHelper { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], ) .await?; - (global_state_pubkey, global_config_pubkey) + // Create the unicast-default topology (required for ActivateLink). + // Must run after SetGlobalConfig, which initializes the AdminGroupBits resource. + let (unicast_default_topology_pubkey, _) = + get_topology_pda(&program_id, "unicast-default"); + let recent_blockhash = banks_client.get_latest_blockhash().await?; + execute_serviceability_instruction( + &mut banks_client, + &payer, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + }), + vec![ + AccountMeta::new(unicast_default_topology_pubkey, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(global_state_pubkey, false), + ], + ) + .await?; + + ( + global_state_pubkey, + global_config_pubkey, + unicast_default_topology_pubkey, + ) }; Ok(Self { @@ -824,6 +856,7 @@ impl ServiceabilityProgramHelper { global_state_pubkey, global_config_pubkey, + unicast_default_topology_pubkey, }) } @@ -1192,6 +1225,7 @@ impl ServiceabilityProgramHelper { AccountMeta::new(side_a_pk, false), AccountMeta::new(side_z_pk, false), AccountMeta::new(self.global_state_pubkey, false), + AccountMeta::new_readonly(self.unicast_default_topology_pubkey, false), ], ) .await?; diff --git a/smartcontract/sdk/go/serviceability/bytereader.go b/smartcontract/sdk/go/serviceability/bytereader.go index fa3fd479c4..441b36b7e3 100644 --- a/smartcontract/sdk/go/serviceability/bytereader.go +++ b/smartcontract/sdk/go/serviceability/bytereader.go @@ -180,3 +180,16 @@ func (br *ByteReader) Skip(n int) { br.offset = len(br.data) } } + +func (br *ByteReader) ReadFlexAlgoNodeSegmentSlice() []FlexAlgoNodeSegment { + length := br.ReadU32() + if length == 0 || (uint64(length)*34) > uint64(br.Remaining()) { + return nil + } + result := make([]FlexAlgoNodeSegment, length) + for i := uint32(0); i < length; i++ { + result[i].Topology = br.ReadPubkey() + result[i].NodeSegmentIdx = br.ReadU16() + } + return result +} diff --git a/smartcontract/sdk/go/serviceability/client_test.go b/smartcontract/sdk/go/serviceability/client_test.go index f52c338379..052e0893be 100644 --- a/smartcontract/sdk/go/serviceability/client_test.go +++ b/smartcontract/sdk/go/serviceability/client_test.go @@ -403,6 +403,8 @@ func TestSDK_Serviceability_GetProgramData(t *testing.T) { SideZIfaceName: "lo0", LinkHealth: LinkHealth(173), LinkDesiredStatus: LinkDesiredStatus(37), + LinkFlags: 118, + LinkTopologies: nil, PubKey: pubkeys[5], }, }, diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index ed0ee87bfe..8d7cf9d401 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -76,8 +76,26 @@ func DeserializeContributor(reader *ByteReader, contributor *Contributor) { contributor.BumpSeed = reader.ReadU8() contributor.Status = ContributorStatus(reader.ReadU8()) contributor.Code = reader.ReadString() - contributor.ReferenceCount = reader.ReadU32() - contributor.OpsManagerPK = reader.ReadPubkey() + contributor.PubKey = reader.ReadPubkey() +} + +func DeserializeTenant(reader *ByteReader, tenant *Tenant) { + tenant.AccountType = AccountType(reader.ReadU8()) + tenant.Owner = reader.ReadPubkey() + tenant.BumpSeed = reader.ReadU8() + tenant.Code = reader.ReadString() + tenant.VrfId = reader.ReadU16() + tenant.ReferenceCount = reader.ReadU32() + tenant.Administrators = reader.ReadPubkeySlice() + tenant.PaymentStatus = TenantPaymentStatus(reader.ReadU8()) + tenant.TokenAccount = reader.ReadPubkey() + tenant.MetroRouting = (reader.ReadU8() != 0) + tenant.RouteLiveness = (reader.ReadU8() != 0) + tenant.BillingDiscriminant = reader.ReadU8() + tenant.BillingRate = reader.ReadU64() + tenant.BillingLastDeductionDzEpoch = reader.ReadU64() + tenant.IncludeTopologies = reader.ReadPubkeySlice() + // Note: tenant.PubKey is set separately in client.go after deserialization } func DeserializeInterface(reader *ByteReader, iface *Interface) { @@ -93,6 +111,8 @@ func DeserializeInterface(reader *ByteReader, iface *Interface) { DeserializeInterfaceV1(reader, iface) case 1: // version 2 DeserializeInterfaceV2(reader, iface) + case 2: // version 3 + DeserializeInterfaceV3(reader, iface) } } @@ -122,6 +142,16 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.IpNet = reader.ReadNetworkV4() iface.NodeSegmentIdx = reader.ReadU16() iface.UserTunnelEndpoint = (reader.ReadU8() != 0) + // flex_algo_node_segments was merged into V2 from the old V3. + // Old V2 accounts (written before this field existed) will have no trailing + // bytes — ReadFlexAlgoNodeSegmentSlice returns nil/empty in that case. + iface.FlexAlgoNodeSegments = reader.ReadFlexAlgoNodeSegmentSlice() +} + +// DeserializeInterfaceV3 handles legacy on-chain accounts written with +// discriminant 2 (the old V3). Their layout is identical to the current V2. +func DeserializeInterfaceV3(reader *ByteReader, iface *Interface) { + DeserializeInterfaceV2(reader, iface) } func DeserializeDevice(reader *ByteReader, dev *Device) { @@ -187,6 +217,8 @@ func DeserializeLink(reader *ByteReader, link *Link) { link.DelayOverrideNs = reader.ReadU64() link.LinkHealth = LinkHealth(reader.ReadU8()) link.LinkDesiredStatus = LinkDesiredStatus(reader.ReadU8()) + link.LinkTopologies = reader.ReadPubkeySlice() + link.LinkFlags = reader.ReadU8() } func DeserializeUser(reader *ByteReader, user *User) { @@ -228,24 +260,6 @@ func DeserializeMulticastGroup(reader *ByteReader, mg *MulticastGroup) { mg.SubscriberCount = reader.ReadU32() } -func DeserializeTenant(reader *ByteReader, tenant *Tenant) { - tenant.AccountType = AccountType(reader.ReadU8()) - tenant.Owner = reader.ReadPubkey() - tenant.BumpSeed = reader.ReadU8() - tenant.Code = reader.ReadString() - tenant.VrfId = reader.ReadU16() - tenant.ReferenceCount = reader.ReadU32() - tenant.Administrators = reader.ReadPubkeySlice() - tenant.PaymentStatus = TenantPaymentStatus(reader.ReadU8()) - tenant.TokenAccount = reader.ReadPubkey() - tenant.MetroRouting = (reader.ReadU8() != 0) - tenant.RouteLiveness = (reader.ReadU8() != 0) - tenant.BillingDiscriminant = reader.ReadU8() - tenant.BillingRate = reader.ReadU64() - tenant.BillingLastDeductionDzEpoch = reader.ReadU64() - // Note: tenant.PubKey is set separately in client.go after deserialization -} - func DeserializeProgramConfig(reader *ByteReader, pc *ProgramConfig) { pc.AccountType = AccountType(reader.ReadU8()) pc.BumpSeed = reader.ReadU8() @@ -352,3 +366,14 @@ func DeserializePermission(reader *ByteReader, perm *Permission) { perm.PermissionsLo = reader.ReadU64() // bits 0-63 (low u64 of u128) perm.PermissionsHi = reader.ReadU64() // bits 64-127 (high u64 of u128) } + +func DeserializeTopologyInfo(reader *ByteReader, t *TopologyInfo) { + t.AccountType = AccountType(reader.ReadU8()) + t.Owner = reader.ReadPubkey() + t.BumpSeed = reader.ReadU8() + t.Name = reader.ReadString() + t.AdminGroupBit = reader.ReadU8() + t.FlexAlgoNumber = reader.ReadU8() + t.Constraint = TopologyConstraint(reader.ReadU8()) + // Note: t.PubKey is set from the account address in client.go after deserialization +} diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 4329b3a852..2e5b6f7cab 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -26,6 +26,7 @@ const ( TenantType // 13 // 14 is reserved PermissionType AccountType = 15 + TopologyType AccountType = 16 ) type LocationStatus uint8 @@ -385,22 +386,28 @@ func (l RoutingMode) MarshalJSON() ([]byte, error) { return json.Marshal(l.String()) } +type FlexAlgoNodeSegment struct { + Topology [32]byte + NodeSegmentIdx uint16 +} + type Interface struct { - Version uint8 - Status InterfaceStatus - Name string - InterfaceType InterfaceType - InterfaceCYOA InterfaceCYOA - InterfaceDIA InterfaceDIA - LoopbackType LoopbackType - Bandwidth uint64 - Cir uint64 - Mtu uint16 - RoutingMode RoutingMode - VlanId uint16 - IpNet [5]uint8 - NodeSegmentIdx uint16 - UserTunnelEndpoint bool + Version uint8 + Status InterfaceStatus + Name string + InterfaceType InterfaceType + InterfaceCYOA InterfaceCYOA + InterfaceDIA InterfaceDIA + LoopbackType LoopbackType + Bandwidth uint64 + Cir uint64 + Mtu uint16 + RoutingMode RoutingMode + VlanId uint16 + IpNet [5]uint8 + NodeSegmentIdx uint16 + UserTunnelEndpoint bool + FlexAlgoNodeSegments []FlexAlgoNodeSegment `json:",omitempty"` } func (i Interface) MarshalJSON() ([]byte, error) { @@ -425,7 +432,7 @@ func (i Interface) MarshalJSON() ([]byte, error) { return json.Marshal(jsonIface) } -const CurrentInterfaceVersion = 2 +const CurrentInterfaceVersion = 3 type Device struct { AccountType AccountType @@ -645,6 +652,8 @@ type Link struct { LinkHealth LinkHealth `influx:"field,link_health"` LinkDesiredStatus LinkDesiredStatus `influx:"tag,link_desired_status"` PubKey [32]byte `influx:"tag,pubkey,pubkey"` + LinkTopologies [][32]byte + LinkFlags uint8 } func (l Link) MarshalJSON() ([]byte, error) { @@ -772,6 +781,7 @@ type Tenant struct { BillingRate uint64 `influx:"field,billing_rate"` BillingLastDeductionDzEpoch uint64 `influx:"field,billing_last_deduction_dz_epoch"` PubKey [32]byte `influx:"tag,pubkey,pubkey"` + IncludeTopologies [][32]byte } func (t Tenant) MarshalJSON() ([]byte, error) { @@ -1284,3 +1294,32 @@ func (r ResourceExtension) MarshalJSON() ([]byte, error) { return json.Marshal(jsonExt) } + +type TopologyConstraint uint8 + +const ( + TopologyConstraintIncludeAny TopologyConstraint = 0 + TopologyConstraintExclude TopologyConstraint = 1 +) + +func (c TopologyConstraint) String() string { + switch c { + case TopologyConstraintIncludeAny: + return "include-any" + case TopologyConstraintExclude: + return "exclude" + default: + return "unknown" + } +} + +type TopologyInfo struct { + AccountType AccountType + Owner [32]byte + BumpSeed uint8 + Name string + AdminGroupBit uint8 + FlexAlgoNumber uint8 + Constraint TopologyConstraint + PubKey [32]byte +} diff --git a/smartcontract/sdk/go/serviceability/state_test.go b/smartcontract/sdk/go/serviceability/state_test.go index c69646ae3f..15d29cebd7 100644 --- a/smartcontract/sdk/go/serviceability/state_test.go +++ b/smartcontract/sdk/go/serviceability/state_test.go @@ -71,6 +71,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "Switch1/1/1", "SideZIfaceName": "Switch1/1/1", "DelayOverrideNs": 10, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, @@ -122,6 +124,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "Edge1/0/0", "SideZIfaceName": "Edge2/0/0", "DelayOverrideNs": 0, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, @@ -153,6 +157,8 @@ func TestCustomJSONMarshal(t *testing.T) { "SideAIfaceName": "", "SideZIfaceName": "", "DelayOverrideNs": 0, + "LinkTopologies": null, + "LinkFlags": 0, "PubKey": "11111111111111111111111111111111" }`, expectErr: false, @@ -304,7 +310,7 @@ func TestCustomJSONMarshal(t *testing.T) { "MgmtVrf": "mgmt-vrf", "Interfaces": [ { - "Version": 1, + "Version": 2, "Status": "activated", "Name": "Switch1/1/1", "InterfaceType": "physical", @@ -414,6 +420,7 @@ func TestCustomJSONMarshal(t *testing.T) { "BillingDiscriminant": 0, "BillingRate": 0, "BillingLastDeductionDzEpoch": 0, + "IncludeTopologies": null, "PubKey": "` + dummyPubKeyB58 + `" }`, expectErr: false, diff --git a/smartcontract/sdk/rs/src/client.rs b/smartcontract/sdk/rs/src/client.rs index a648f4af09..0581b78c1a 100644 --- a/smartcontract/sdk/rs/src/client.rs +++ b/smartcontract/sdk/rs/src/client.rs @@ -144,18 +144,21 @@ impl DZClient { .ok_or_eyre("No default signer found, run \"doublezero keygen\" to create a new one")?; let data = instruction.pack(); + let payer_pubkey = payer.pubkey(); + let mut all_accounts = accounts; + if !all_accounts + .iter() + .any(|a| a.pubkey == payer_pubkey && a.is_signer) + { + all_accounts.push(AccountMeta::new(payer_pubkey, true)); + } + all_accounts.push(AccountMeta::new(program::id(), false)); + let mut transaction = Transaction::new_with_payer( &[Instruction::new_with_bytes( self.program_id, &data, - [ - accounts, - vec![ - AccountMeta::new(payer.pubkey(), true), - AccountMeta::new(program::id(), false), - ], - ] - .concat(), + all_accounts, )], Some(&payer.pubkey()), ); diff --git a/smartcontract/sdk/rs/src/commands/contributor/create.rs b/smartcontract/sdk/rs/src/commands/contributor/create.rs index 229f0fddcf..257d0e2afb 100644 --- a/smartcontract/sdk/rs/src/commands/contributor/create.rs +++ b/smartcontract/sdk/rs/src/commands/contributor/create.rs @@ -30,6 +30,7 @@ impl CreateContributorCommand { AccountMeta::new(pda_pubkey, false), AccountMeta::new(self.owner, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(client.get_payer(), true), // TEMP: workaround for --owner me payer bug ], ) .map(|sig| (sig, pda_pubkey)) diff --git a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs index bd0885a756..52495758bf 100644 --- a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs +++ b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs @@ -46,6 +46,8 @@ impl SetGlobalConfigCommand { ); let (vrf_ids_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::VrfIds); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); client.execute_transaction( DoubleZeroInstruction::SetGlobalConfig(set_config_args), @@ -59,6 +61,7 @@ impl SetGlobalConfigCommand { AccountMeta::new(segment_routing_ids_pda, false), AccountMeta::new(multicast_publisher_block_pda, false), AccountMeta::new(vrf_ids_pda, false), + AccountMeta::new(admin_group_bits_pda, false), ], ) } diff --git a/smartcontract/sdk/rs/src/commands/link/accept.rs b/smartcontract/sdk/rs/src/commands/link/accept.rs index ec736b3aa7..387bde3d34 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -134,6 +134,9 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let device_z = doublezero_serviceability::state::device::Device { @@ -235,6 +238,9 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; let device_z = doublezero_serviceability::state::device::Device { diff --git a/smartcontract/sdk/rs/src/commands/link/activate.rs b/smartcontract/sdk/rs/src/commands/link/activate.rs index b3e439bb27..aa2a4c4b73 100644 --- a/smartcontract/sdk/rs/src/commands/link/activate.rs +++ b/smartcontract/sdk/rs/src/commands/link/activate.rs @@ -4,8 +4,11 @@ use crate::{ }; use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ - instructions::DoubleZeroInstruction, pda::get_resource_extension_pda, - processors::link::activate::LinkActivateArgs, resource::ResourceType, state::link::LinkStatus, + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::link::activate::LinkActivateArgs, + resource::ResourceType, + state::link::LinkStatus, }; use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; @@ -61,6 +64,11 @@ impl ActivateLinkCommand { accounts.push(AccountMeta::new(link_ids_ext, false)); } + // Always include the unicast-default topology account (required by the on-chain program) + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); + accounts.push(AccountMeta::new_readonly(unicast_default_pda, false)); + client.execute_transaction( DoubleZeroInstruction::ActivateLink(LinkActivateArgs { tunnel_id: self.tunnel_id, @@ -81,7 +89,7 @@ mod tests { use doublezero_program_common::types::NetworkV4; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::{get_globalstate_pda, get_resource_extension_pda}, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, processors::link::activate::LinkActivateArgs, resource::ResourceType, state::{ @@ -98,6 +106,8 @@ mod tests { let mut client = create_test_client(); let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); let link_pubkey = Pubkey::new_unique(); let side_a_pk = Pubkey::new_unique(); let side_z_pk = Pubkey::new_unique(); @@ -126,6 +136,9 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; // Mock Link fetch @@ -147,6 +160,7 @@ mod tests { AccountMeta::new(side_a_pk, false), AccountMeta::new(side_z_pk, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new_readonly(unicast_default_pda, false), ]), ) .returning(|_, _| Ok(Signature::new_unique())); @@ -169,6 +183,8 @@ mod tests { let mut client = create_test_client(); let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (unicast_default_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); let link_pubkey = Pubkey::new_unique(); let side_a_pk = Pubkey::new_unique(); let side_z_pk = Pubkey::new_unique(); @@ -195,6 +211,9 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; // Compute ResourceExtension PDAs @@ -224,6 +243,7 @@ mod tests { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_ext, false), AccountMeta::new(link_ids_ext, false), + AccountMeta::new_readonly(unicast_default_pda, false), ]), ) .returning(|_, _| Ok(Signature::new_unique())); diff --git a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs index bd5cd13b74..a6424c700b 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -115,6 +115,9 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; // Mock Link fetch @@ -185,6 +188,9 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, }; // Compute ResourceExtension PDAs diff --git a/smartcontract/sdk/rs/src/commands/link/delete.rs b/smartcontract/sdk/rs/src/commands/link/delete.rs index 181fdd478a..979ba522b6 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -106,6 +106,9 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Activated, desired_status: LinkDesiredStatus::Activated, + + link_topologies: Vec::new(), + link_flags: 0, } } diff --git a/smartcontract/sdk/rs/src/commands/link/update.rs b/smartcontract/sdk/rs/src/commands/link/update.rs index 3df2feff7b..28b3fb82cd 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -27,6 +27,8 @@ pub struct UpdateLinkCommand { pub desired_status: Option, pub tunnel_id: Option, pub tunnel_net: Option, + pub link_topologies: Option>, + pub unicast_drained: Option, } impl UpdateLinkCommand { @@ -100,6 +102,8 @@ impl UpdateLinkCommand { tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, use_onchain_allocation, + link_topologies: self.link_topologies.clone(), + unicast_drained: self.unicast_drained, }), accounts, ) diff --git a/smartcontract/sdk/rs/src/commands/mod.rs b/smartcontract/sdk/rs/src/commands/mod.rs index b5f49ff28b..77bc50a37e 100644 --- a/smartcontract/sdk/rs/src/commands/mod.rs +++ b/smartcontract/sdk/rs/src/commands/mod.rs @@ -14,4 +14,5 @@ pub mod permission; pub mod programconfig; pub mod resource; pub mod tenant; +pub mod topology; pub mod user; diff --git a/smartcontract/sdk/rs/src/commands/tenant/delete.rs b/smartcontract/sdk/rs/src/commands/tenant/delete.rs index f4580b0ae1..eed416badb 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/delete.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/delete.rs @@ -250,6 +250,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mut seq = Sequence::new(); diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index cd175fed5c..97a7eb109d 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -13,6 +13,7 @@ pub struct UpdateTenantCommand { pub metro_routing: Option, pub route_liveness: Option, pub billing: Option, + pub include_topologies: Option>, } impl UpdateTenantCommand { @@ -26,6 +27,7 @@ impl UpdateTenantCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing: self.billing, + include_topologies: self.include_topologies.clone(), }), vec![ AccountMeta::new(self.tenant_pubkey, false), diff --git a/smartcontract/sdk/rs/src/commands/topology/backfill.rs b/smartcontract/sdk/rs/src/commands/topology/backfill.rs new file mode 100644 index 0000000000..7342ba2cc3 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/backfill.rs @@ -0,0 +1,138 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::topology::backfill::TopologyBackfillArgs, + resource::ResourceType, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct BackfillTopologyCommand { + pub name: String, + pub device_pubkeys: Vec, +} + +impl BackfillTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + + let payer = client.get_payer(); + + let mut accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(segment_routing_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]; + + for device_pk in &self.device_pubkeys { + accounts.push(AccountMeta::new(*device_pk, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: self.name.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::backfill::BackfillTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, + processors::topology::backfill::TopologyBackfillArgs, + resource::ResourceType, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_topology_backfill_no_devices() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + let (sr_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + let payer = client.get_payer(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::BackfillTopology( + TopologyBackfillArgs { + name: "unicast-default".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(sr_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = BackfillTopologyCommand { + name: "unicast-default".to_string(), + device_pubkeys: vec![], + } + .execute(&client); + + assert!(res.is_ok()); + } + + #[test] + fn test_commands_topology_backfill_with_devices() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "algo128"); + let (sr_ids_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + let payer = client.get_payer(); + let device1 = Pubkey::new_unique(); + let device2 = Pubkey::new_unique(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::BackfillTopology( + TopologyBackfillArgs { + name: "algo128".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new(sr_ids_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + AccountMeta::new(device1, false), + AccountMeta::new(device2, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = BackfillTopologyCommand { + name: "algo128".to_string(), + device_pubkeys: vec![device1, device2], + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/clear.rs b/smartcontract/sdk/rs/src/commands/topology/clear.rs new file mode 100644 index 0000000000..c2b57595b4 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/clear.rs @@ -0,0 +1,122 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_topology_pda, + processors::topology::clear::TopologyClearArgs, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct ClearTopologyCommand { + pub name: String, + pub link_pubkeys: Vec, +} + +impl ClearTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + + let payer = client.get_payer(); + + let mut accounts = vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]; + + for link_pk in &self.link_pubkeys { + accounts.push(AccountMeta::new(*link_pk, false)); + } + + client.execute_transaction( + DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: self.name.clone(), + }), + accounts, + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::clear::ClearTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_topology_pda}, + processors::topology::clear::TopologyClearArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + + #[test] + fn test_commands_topology_clear_command_no_links() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let payer = client.get_payer(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "my-topology".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = ClearTopologyCommand { + name: "my-topology".to_string(), + link_pubkeys: vec![], + } + .execute(&client); + + assert!(res.is_ok()); + } + + #[test] + fn test_commands_topology_clear_command_with_links() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "my-topology"); + let payer = client.get_payer(); + let link1 = Pubkey::new_unique(); + let link2 = Pubkey::new_unique(); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::ClearTopology(TopologyClearArgs { + name: "my-topology".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new_readonly(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(payer, true), + AccountMeta::new(link1, false), + AccountMeta::new(link2, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = ClearTopologyCommand { + name: "my-topology".to_string(), + link_pubkeys: vec![link1, link2], + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/create.rs b/smartcontract/sdk/rs/src/commands/topology/create.rs new file mode 100644 index 0000000000..f63344072b --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/create.rs @@ -0,0 +1,107 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_resource_extension_pda, get_topology_pda}, + processors::topology::create::TopologyCreateArgs, + resource::ResourceType, + state::topology::TopologyConstraint, +}; +use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct CreateTopologyCommand { + pub name: String, + pub constraint: TopologyConstraint, +} + +impl CreateTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result<(Signature, Pubkey)> { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + + // Pre-flight: verify admin-group-bits resource account exists + client.get_account(admin_group_bits_pda).map_err(|_| { + eyre::eyre!( + "admin-group-bits resource account not found ({}). \ + Run 'doublezero resource create --resource-type admin-group-bits' first.", + admin_group_bits_pda + ) + })?; + + client + .execute_transaction( + DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: self.name.clone(), + constraint: self.constraint, + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + ) + .map(|sig| (sig, topology_pda)) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::create::CreateTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_resource_extension_pda, get_topology_pda}, + processors::topology::create::TopologyCreateArgs, + resource::ResourceType, + state::topology::TopologyConstraint, + }; + use mockall::predicate; + use solana_sdk::{account::Account, instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_topology_create_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + let (admin_group_bits_pda, _, _) = + get_resource_extension_pda(&client.get_program_id(), ResourceType::AdminGroupBits); + + client + .expect_get_account() + .with(predicate::eq(admin_group_bits_pda)) + .returning(|_| Ok(Account::default())); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::CreateTopology(TopologyCreateArgs { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + })), + predicate::eq(vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new(admin_group_bits_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = CreateTopologyCommand { + name: "unicast-default".to_string(), + constraint: TopologyConstraint::IncludeAny, + } + .execute(&client); + + assert!(res.is_ok()); + let (_, pda) = res.unwrap(); + assert_eq!(pda, topology_pda); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/delete.rs b/smartcontract/sdk/rs/src/commands/topology/delete.rs new file mode 100644 index 0000000000..df5d0ec6e7 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/delete.rs @@ -0,0 +1,74 @@ +use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, pda::get_topology_pda, + processors::topology::delete::TopologyDeleteArgs, +}; +use solana_sdk::{instruction::AccountMeta, signature::Signature}; + +#[derive(Debug, PartialEq, Clone)] +pub struct DeleteTopologyCommand { + pub name: String, +} + +impl DeleteTopologyCommand { + pub fn execute(&self, client: &dyn DoubleZeroClient) -> eyre::Result { + let (globalstate_pubkey, _globalstate) = GetGlobalStateCommand + .execute(client) + .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), &self.name); + + client.execute_transaction( + DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: self.name.clone(), + }), + vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + ) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + commands::topology::delete::DeleteTopologyCommand, tests::utils::create_test_client, + DoubleZeroClient, + }; + use doublezero_serviceability::{ + instructions::DoubleZeroInstruction, + pda::{get_globalstate_pda, get_topology_pda}, + processors::topology::delete::TopologyDeleteArgs, + }; + use mockall::predicate; + use solana_sdk::{instruction::AccountMeta, signature::Signature}; + + #[test] + fn test_commands_topology_delete_command() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (topology_pda, _) = get_topology_pda(&client.get_program_id(), "unicast-default"); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::DeleteTopology(TopologyDeleteArgs { + name: "unicast-default".to_string(), + })), + predicate::eq(vec![ + AccountMeta::new(topology_pda, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = DeleteTopologyCommand { + name: "unicast-default".to_string(), + } + .execute(&client); + + assert!(res.is_ok()); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/list.rs b/smartcontract/sdk/rs/src/commands/topology/list.rs new file mode 100644 index 0000000000..e19956b09c --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/list.rs @@ -0,0 +1,85 @@ +use crate::DoubleZeroClient; +use doublezero_serviceability::{ + error::DoubleZeroError, + state::{accountdata::AccountData, accounttype::AccountType, topology::TopologyInfo}, +}; +use solana_sdk::pubkey::Pubkey; +use std::collections::HashMap; + +#[derive(Debug, PartialEq, Clone)] +pub struct ListTopologyCommand; + +impl ListTopologyCommand { + pub fn execute( + &self, + client: &dyn DoubleZeroClient, + ) -> eyre::Result> { + client + .gets(AccountType::Topology)? + .into_iter() + .map(|(k, v)| match v { + AccountData::Topology(topology) => Ok((k, topology)), + _ => Err(DoubleZeroError::InvalidAccountType.into()), + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{commands::topology::list::ListTopologyCommand, tests::utils::create_test_client}; + use doublezero_serviceability::state::{ + accountdata::AccountData, + accounttype::AccountType, + topology::{TopologyConstraint, TopologyInfo}, + }; + use mockall::predicate; + use solana_sdk::pubkey::Pubkey; + + #[test] + fn test_commands_topology_list_command() { + let mut client = create_test_client(); + + let topology1_pubkey = Pubkey::new_unique(); + let topology1 = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 1, + name: "unicast-default".to_string(), + admin_group_bit: 0, + flex_algo_number: 128, + constraint: TopologyConstraint::IncludeAny, + }; + + let topology2_pubkey = Pubkey::new_unique(); + let topology2 = TopologyInfo { + account_type: AccountType::Topology, + owner: Pubkey::new_unique(), + bump_seed: 2, + name: "exclude-test".to_string(), + admin_group_bit: 2, + flex_algo_number: 130, + constraint: TopologyConstraint::Exclude, + }; + + client + .expect_gets() + .with(predicate::eq(AccountType::Topology)) + .returning(move |_| { + let mut topologies = HashMap::new(); + topologies.insert(topology1_pubkey, AccountData::Topology(topology1.clone())); + topologies.insert(topology2_pubkey, AccountData::Topology(topology2.clone())); + Ok(topologies) + }); + + let res = ListTopologyCommand.execute(&client); + + assert!(res.is_ok()); + let list = res.unwrap(); + assert_eq!(list.len(), 2); + assert!(list.contains_key(&topology1_pubkey)); + assert!(list.contains_key(&topology2_pubkey)); + } +} diff --git a/smartcontract/sdk/rs/src/commands/topology/mod.rs b/smartcontract/sdk/rs/src/commands/topology/mod.rs new file mode 100644 index 0000000000..01fa6fd760 --- /dev/null +++ b/smartcontract/sdk/rs/src/commands/topology/mod.rs @@ -0,0 +1,5 @@ +pub mod backfill; +pub mod clear; +pub mod create; +pub mod delete; +pub mod list; diff --git a/smartcontract/sdk/rs/src/lib.rs b/smartcontract/sdk/rs/src/lib.rs index 904dfac968..5f9e21d07b 100644 --- a/smartcontract/sdk/rs/src/lib.rs +++ b/smartcontract/sdk/rs/src/lib.rs @@ -8,7 +8,7 @@ pub use doublezero_serviceability::{ pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, get_location_pda, get_multicastgroup_pda, get_permission_pda, get_resource_extension_pda, - get_tenant_pda, get_user_old_pda, + get_tenant_pda, get_topology_pda, get_user_old_pda, }, programversion::ProgramVersion, resource::{IdOrIp, ResourceType}, @@ -30,6 +30,7 @@ pub use doublezero_serviceability::{ programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, + topology::{TopologyConstraint, TopologyInfo}, user::{BGPStatus, User, UserCYOA, UserStatus, UserType}, }, };