From 406e0061fae3635c729e6a20581b1b62d51ff15e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Wed, 8 Apr 2026 17:11:31 -0500 Subject: [PATCH 1/5] smartcontract: add TopologyInfo account, flex-algo state, and topology processors --- CHANGELOG.md | 4 + activator/src/process/link.rs | 10 + activator/src/processor.rs | 4 + client/doublezero/src/command/connect.rs | 2 + client/doublezero/src/dzd_latency.rs | 1 + smartcontract/cli/src/accesspass/get.rs | 1 + .../cli/src/device/interface/create.rs | 2 + .../cli/src/device/interface/delete.rs | 2 + smartcontract/cli/src/device/interface/get.rs | 1 + .../cli/src/device/interface/list.rs | 2 + .../cli/src/device/interface/update.rs | 5 + smartcontract/cli/src/link/accept.rs | 2 + smartcontract/cli/src/link/delete.rs | 2 + smartcontract/cli/src/link/dzx_create.rs | 2 + smartcontract/cli/src/link/get.rs | 2 + smartcontract/cli/src/link/latency.rs | 2 + smartcontract/cli/src/link/list.rs | 18 + smartcontract/cli/src/link/sethealth.rs | 4 + smartcontract/cli/src/link/update.rs | 4 + smartcontract/cli/src/link/wan_create.rs | 2 + .../cli/src/tenant/add_administrator.rs | 1 + smartcontract/cli/src/tenant/create.rs | 1 + smartcontract/cli/src/tenant/delete.rs | 3 + smartcontract/cli/src/tenant/get.rs | 1 + smartcontract/cli/src/tenant/list.rs | 1 + .../cli/src/tenant/remove_administrator.rs | 1 + smartcontract/cli/src/tenant/update.rs | 1 + .../cli/src/tenant/update_payment_status.rs | 1 + smartcontract/cli/src/user/get.rs | 2 + smartcontract/cli/src/user/list.rs | 4 + .../src/entrypoint.rs | 16 + .../src/instructions.rs | 52 + .../doublezero-serviceability/src/pda.rs | 21 +- .../src/processors/device/interface/create.rs | 1 + .../src/processors/globalconfig/set.rs | 32 + .../src/processors/link/activate.rs | 19 +- .../src/processors/link/create.rs | 2 + .../src/processors/link/update.rs | 64 +- .../src/processors/mod.rs | 1 + .../src/processors/resource/mod.rs | 1 + .../src/processors/tenant/create.rs | 1 + .../src/processors/tenant/update.rs | 9 +- .../src/processors/topology/backfill.rs | 174 ++ .../src/processors/topology/clear.rs | 90 + .../src/processors/topology/create.rs | 199 ++ .../src/processors/topology/delete.rs | 88 + .../src/processors/topology/mod.rs | 4 + .../doublezero-serviceability/src/resource.rs | 2 + .../doublezero-serviceability/src/seeds.rs | 2 + .../src/state/accountdata.rs | 16 +- .../src/state/accounttype.rs | 3 + .../src/state/device.rs | 3 + .../src/state/interface.rs | 90 +- .../src/state/link.rs | 48 +- .../src/state/mod.rs | 1 + .../src/state/tenant.rs | 17 +- .../src/state/topology.rs | 83 + .../tests/accesspass_allow_multiple_ip.rs | 3 + .../tests/create_subscribe_user_test.rs | 30 +- .../tests/delete_cyoa_interface_test.rs | 6 +- .../tests/device_test.rs | 14 +- .../tests/device_update_location_test.rs | 11 +- .../tests/exchange_setdevice.rs | 3 + .../tests/exchange_test.rs | 24 +- .../tests/global_test.rs | 13 + .../tests/interface_test.rs | 9 + .../tests/link_dzx_test.rs | 14 + .../tests/link_onchain_allocation_test.rs | 31 + .../tests/link_wan_test.rs | 747 +++++ .../tests/multicastgroup_subscribe_test.rs | 3 + .../tests/tenant_test.rs | 283 +- .../tests/test_helpers.rs | 56 +- .../tests/topology_test.rs | 2415 +++++++++++++++++ .../tests/unlink_device_interface_test.rs | 10 + .../tests/user_bgp_status_test.rs | 3 + .../tests/user_migration.rs | 3 + .../tests/user_old_test.rs | 3 + .../tests/user_onchain_allocation_test.rs | 22 +- .../tests/user_tests.rs | 9 + ...initialize_device_latency_samples_tests.rs | 2 + .../tests/test_helpers.rs | 40 +- .../sdk/rs/src/commands/globalconfig/set.rs | 3 + .../sdk/rs/src/commands/link/accept.rs | 4 + .../sdk/rs/src/commands/link/activate.rs | 24 +- .../sdk/rs/src/commands/link/closeaccount.rs | 4 + .../sdk/rs/src/commands/link/delete.rs | 2 + .../sdk/rs/src/commands/link/update.rs | 2 + .../sdk/rs/src/commands/tenant/delete.rs | 1 + .../sdk/rs/src/commands/tenant/update.rs | 1 + 89 files changed, 4846 insertions(+), 76 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/clear.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/topology/mod.rs create mode 100644 smartcontract/programs/doublezero-serviceability/src/state/topology.rs create mode 100644 smartcontract/programs/doublezero-serviceability/tests/topology_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8e8d5593..aa3ae1249f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ All notable changes to this project will be documented in this file. - Extend `validate_program_account!` migration to remaining user and multicastgroup allowlist processors (`set_bgp_status`, `delete`, `closeaccount`, publisher/subscriber `add`/`remove`) - Add `OutboundIcmp` target type (`= 2`) to the geolocation onchain program, enabling ICMP-based probing as an alternative to TWAMP for outbound geolocation targets - Allow pending users with subs to be deleted + - 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 - Telemetry - Device telemetry agent now posts `agent_version` and `agent_commit` in the `DeviceLatencySamplesHeader` when initializing new sample accounts, enabling version attribution of onchain telemetry data - Add optional TLS support to state-ingest server via `--tls-cert-file` and `--tls-key-file` flags; when set, the server listens on both HTTP (`:8080`) and HTTPS (`:8443`) simultaneously diff --git a/activator/src/process/link.rs b/activator/src/process/link.rs index fee82e46be..860bb38b3c 100644 --- a/activator/src/process/link.rs +++ b/activator/src/process/link.rs @@ -271,6 +271,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let tunnel_cloned = tunnel.clone(); @@ -397,6 +399,8 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let link_cloned = link.clone(); @@ -457,6 +461,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let tunnel_clone = tunnel.clone(); @@ -544,6 +550,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; // SDK command fetches the link internally @@ -623,6 +631,8 @@ mod tests { link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; // SDK command fetches the link internally diff --git a/activator/src/processor.rs b/activator/src/processor.rs index 60c87f65a6..e6ec3395b1 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -770,6 +770,8 @@ mod tests { side_z_iface_name: "Ethernet1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let mut existing_links: HashMap = HashMap::new(); @@ -804,6 +806,8 @@ mod tests { side_z_iface_name: "Ethernet3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::Pending, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let new_link_cloned = new_link.clone(); 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 2a7a7c4582..ed2075660e 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/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/link/accept.rs b/smartcontract/cli/src/link/accept.rs index 985f5421b8..d409ce49dd 100644 --- a/smartcontract/cli/src/link/accept.rs +++ b/smartcontract/cli/src/link/accept.rs @@ -251,6 +251,8 @@ mod tests { side_z_iface_name: "Ethernet1/2".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/delete.rs b/smartcontract/cli/src/link/delete.rs index b50280354c..b09e3af45e 100644 --- a/smartcontract/cli/src/link/delete.rs +++ b/smartcontract/cli/src/link/delete.rs @@ -151,6 +151,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + 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..696bebb8f1 100644 --- a/smartcontract/cli/src/link/get.rs +++ b/smartcontract/cli/src/link/get.rs @@ -158,6 +158,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let contributor = Contributor { diff --git a/smartcontract/cli/src/link/latency.rs b/smartcontract/cli/src/link/latency.rs index 8e4a479cc9..5514050dac 100644 --- a/smartcontract/cli/src/link/latency.rs +++ b/smartcontract/cli/src/link/latency.rs @@ -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![], + link_flags: 0, } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 39eaf7d061..bd8ecc30a8 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -377,6 +377,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -571,6 +573,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let tunnel2_pubkey = Pubkey::new_unique(); let tunnel2 = Link { @@ -595,6 +599,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -743,6 +749,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -768,6 +776,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -916,6 +926,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -941,6 +953,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client.expect_list_link().returning(move |_| { @@ -1056,6 +1070,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); @@ -1081,6 +1097,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client.expect_list_link().returning(move |_| { diff --git a/smartcontract/cli/src/link/sethealth.rs b/smartcontract/cli/src/link/sethealth.rs index 90b9a5ac6a..198b1e42e3 100644 --- a/smartcontract/cli/src/link/sethealth.rs +++ b/smartcontract/cli/src/link/sethealth.rs @@ -105,6 +105,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let link2 = Link { @@ -129,6 +131,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client diff --git a/smartcontract/cli/src/link/update.rs b/smartcontract/cli/src/link/update.rs index df36333011..775a156bed 100644 --- a/smartcontract/cli/src/link/update.rs +++ b/smartcontract/cli/src/link/update.rs @@ -212,6 +212,8 @@ mod tests { side_z_iface_name: "eth1".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let link2 = Link { @@ -236,6 +238,8 @@ mod tests { side_z_iface_name: "eth3".to_string(), link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; client 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..0b1e82acb7 100644 --- a/smartcontract/cli/src/tenant/get.rs +++ b/smartcontract/cli/src/tenant/get.rs @@ -103,6 +103,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant_cloned = tenant.clone(); diff --git a/smartcontract/cli/src/tenant/list.rs b/smartcontract/cli/src/tenant/list.rs index acc2b72e79..20d06a8e46 100644 --- a/smartcontract/cli/src/tenant/list.rs +++ b/smartcontract/cli/src/tenant/list.rs @@ -91,6 +91,7 @@ mod tests { metro_routing: true, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/remove_administrator.rs b/smartcontract/cli/src/tenant/remove_administrator.rs index 5e9deebbb9..db27bd8483 100644 --- a/smartcontract/cli/src/tenant/remove_administrator.rs +++ b/smartcontract/cli/src/tenant/remove_administrator.rs @@ -83,6 +83,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/update.rs b/smartcontract/cli/src/tenant/update.rs index a304eb559c..fbd8cfc83b 100644 --- a/smartcontract/cli/src/tenant/update.rs +++ b/smartcontract/cli/src/tenant/update.rs @@ -111,6 +111,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/tenant/update_payment_status.rs b/smartcontract/cli/src/tenant/update_payment_status.rs index b5843b093e..2d579c4bd8 100644 --- a/smartcontract/cli/src/tenant/update_payment_status.rs +++ b/smartcontract/cli/src/tenant/update_payment_status.rs @@ -84,6 +84,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; client diff --git a/smartcontract/cli/src/user/get.rs b/smartcontract/cli/src/user/get.rs index ea89489303..8a6e05a80a 100644 --- a/smartcontract/cli/src/user/get.rs +++ b/smartcontract/cli/src/user/get.rs @@ -197,6 +197,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let device_pubkey = Pubkey::new_unique(); @@ -389,6 +390,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 601fd17eb1..f6aeb19927 100644 --- a/smartcontract/cli/src/user/list.rs +++ b/smartcontract/cli/src/user/list.rs @@ -1465,6 +1465,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1480,6 +1481,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); @@ -1640,6 +1642,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let tenant2 = Tenant { @@ -1655,6 +1658,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..ab8d439871 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,11 @@ 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 { @@ -360,6 +369,11 @@ impl DoubleZeroInstruction { 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 +511,11 @@ 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 +646,11 @@ 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 +850,8 @@ mod tests { tunnel_id: None, tunnel_net: None, use_onchain_allocation: false, + link_topologies: None, + unicast_drained: None, }), "UpdateLink", ); @@ -1276,6 +1302,7 @@ mod tests { metro_routing: Some(true), route_liveness: Some(false), billing: None, + include_topologies: None, }), "UpdateTenant", ); @@ -1322,5 +1349,30 @@ 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", + ); + test_instruction( + DoubleZeroInstruction::BackfillTopology(TopologyBackfillArgs { + name: "unicast-default".to_string(), + }), + "BackfillTopology", + ); } } diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index 7e8e0e1bcf..0283657906 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, }; @@ -103,6 +103,10 @@ pub fn get_accesspass_pda( ) } +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_index_pda(program_id: &Pubkey, entity_seed: &[u8], key: &str) -> (Pubkey, u8) { let lowercase_key = key.to_ascii_lowercase(); Pubkey::find_program_address( @@ -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..8560b84622 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/create.rs @@ -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..9387e8b41f 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); @@ -364,7 +380,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 +477,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 +532,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..0f5093b1e7 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/tenant/update.rs @@ -25,14 +25,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 +96,9 @@ pub fn process_update_tenant( if let Some(billing) = value.billing { tenant.billing = billing; } + if let Some(include_topologies) = value.include_topologies.clone() { + tenant.include_topologies = include_topologies; + } 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..b9bd9fa392 --- /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::V3(ref mut v3) => { + v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + } + _ => { + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_key, + node_segment_idx, + }); + *iface = Interface::V3(upgraded); + } + } + modified = true; + backfilled_count += 1; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + + msg!( + "TopologyBackfill: '{}' — {} loopback(s) backfilled, {} already had segment", + value.name, + backfilled_count, + skipped_count + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/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..ed3fec4f3e --- /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+] Device accounts (writable) — optional, for backfill +/// +/// If no Device accounts 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 V3 if needed + match iface { + Interface::V3(ref mut v3) => { + v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + } + _ => { + // Upgrade to current version (V3) with the segment added + let mut upgraded = iface.into_current_version(); + upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { + topology: *topology_account.key, + node_segment_idx, + }); + *iface = Interface::V3(upgraded); + } + } + modified = true; + } + if modified { + try_acc_write(&device, device_account, payer_account, accounts)?; + } + } + } + + msg!( + "TopologyCreate: created '{}' bit={} algo={} constraint={:?}", + value.name, + admin_group_bit, + flex_algo_number, + value.constraint + ); + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/delete.rs new file mode 100644 index 0000000000..735ccff9a3 --- /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 validate it exists + 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..d6a09823ae 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 { @@ -316,11 +317,11 @@ impl InterfaceV2 { } pub fn to_interface(&self) -> Interface { - Interface::V2(self.clone()) + Interface::V3(self.clone()) } pub fn size_given_name_len(name_len: usize) -> usize { - 1 + 4 + name_len + 1 + 1 + 1 + 1 + 8 + 8 + 2 + 1 + 2 + 5 + 2 + 1 + 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,23 +390,75 @@ 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![], } } } #[repr(u8)] -#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] +#[derive(BorshSerialize, Debug, PartialEq, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[borsh(use_discriminant = true)] pub enum Interface { - V1(InterfaceV1), - V2(InterfaceV2), + V1(InterfaceV1) = 0, + /// Discriminant 1: old on-chain V2 format. Does NOT include + /// flex_algo_node_segments bytes. Read-only — new accounts use V3. + V2(InterfaceV2) = 1, + /// Discriminant 3: current format. Includes flex_algo_node_segments. + /// Discriminant 2 is intentionally skipped (reserved for future use). + V3(InterfaceV2) = 3, +} + +impl borsh::BorshDeserialize for Interface { + fn deserialize_reader(reader: &mut R) -> borsh::io::Result { + let discriminant: u8 = borsh::BorshDeserialize::deserialize_reader(reader)?; + match discriminant { + 0 => Ok(Interface::V1(borsh::BorshDeserialize::deserialize_reader( + reader, + )?)), + 1 | 2 => { + // Old on-chain V2 format. flex_algo_node_segments is NOT present + // in these accounts — reading it would consume bytes from the next + // field in the parent buffer and corrupt deserialization. + Ok(Interface::V2(InterfaceV2 { + status: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), + name: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), + interface_type: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + interface_cyoa: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + interface_dia: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + loopback_type: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + bandwidth: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + cir: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), + mtu: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), + routing_mode: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + vlan_id: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + ip_net: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), + node_segment_idx: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + user_tunnel_endpoint: borsh::BorshDeserialize::deserialize_reader(reader) + .unwrap_or_default(), + flex_algo_node_segments: vec![], + })) + } + 3 => Ok(Interface::V3(borsh::BorshDeserialize::deserialize_reader( + reader, + )?)), + _ => Ok(Interface::V2(InterfaceV2::default())), + } + } } pub type CurrentInterfaceVersion = InterfaceV2; @@ -409,14 +467,14 @@ impl Interface { pub fn into_current_version(&self) -> CurrentInterfaceVersion { match self { Interface::V1(v1) => v1.try_into().unwrap_or_default(), - Interface::V2(v2) => v2.clone(), + Interface::V2(v2) | Interface::V3(v2) => v2.clone(), } } pub fn size(&self) -> usize { let base_size = match self { Interface::V1(v1) => v1.size(), - Interface::V2(v2) => v2.size(), + Interface::V2(v2) | Interface::V3(v2) => v2.size(), }; base_size + 1 // +1 for the enum discriminant } @@ -478,11 +536,8 @@ impl Validate for Interface { impl TryFrom<&[u8]> for Interface { type Error = ProgramError; - 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 - } + fn try_from(data: &[u8]) -> Result { + BorshDeserialize::deserialize(&mut &data[..]).map_err(|_| ProgramError::InvalidAccountData) } } @@ -528,12 +583,13 @@ 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(); assert!( - matches!(iface, Interface::V2(_)), - "iface is not Interface::V2" + matches!(iface, Interface::V3(_)), + "iface is not Interface::V3" ); let iface_v2: CurrentInterfaceVersion = iface.into_current_version(); assert_eq!(iface_v2.name, "Loopback0"); @@ -566,6 +622,7 @@ mod test_interface_validate { ip_net: NetworkV4::default(), node_segment_idx: 0, user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], } } @@ -671,6 +728,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..30525e7fe5 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,11 +414,23 @@ 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(()) } } impl Link { + pub fn is_unicast_drained(&self) -> bool { + self.link_flags & LINK_FLAG_UNICAST_DRAINED != 0 + } + /// Checks and updates the `status` of the `Link` based on its current `status`, `desired_status`, and `link_health`. /// /// The transition logic is as follows: @@ -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,6 +948,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, }; assert!(bad_link.validate().is_ok()); } 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 c18cab5ec4..aabb267916 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/create_subscribe_user_test.rs @@ -91,13 +91,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); @@ -114,6 +114,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( @@ -153,6 +155,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, ) @@ -1530,13 +1533,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); @@ -1553,6 +1556,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( @@ -1592,6 +1597,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, ) @@ -1863,6 +1869,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( @@ -1893,6 +1900,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( @@ -1949,6 +1958,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, ) @@ -2219,6 +2229,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( @@ -2249,6 +2260,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( @@ -2288,6 +2301,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_bgp_status_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_bgp_status_test.rs index 510bff61a4..f888c3dd61 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_bgp_status_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_bgp_status_test.rs @@ -83,6 +83,8 @@ async fn setup() -> BgpStatusTestEnv { 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, @@ -120,6 +122,7 @@ async fn setup() -> BgpStatusTestEnv { 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_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 c7ff4a6af7..b51210e900 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 @@ -712,6 +712,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 d6bec16a2a..0a860bc303 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::{ @@ -742,6 +744,7 @@ pub struct ServiceabilityProgramHelper { pub global_state_pubkey: Pubkey, pub global_config_pubkey: Pubkey, + pub unicast_default_topology_pubkey: Pubkey, } impl ServiceabilityProgramHelper { @@ -749,7 +752,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(); ( @@ -789,6 +792,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, @@ -813,11 +818,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 { @@ -826,6 +858,7 @@ impl ServiceabilityProgramHelper { global_state_pubkey, global_config_pubkey, + unicast_default_topology_pubkey, }) } @@ -1194,6 +1227,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/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..81ef0ac89b 100644 --- a/smartcontract/sdk/rs/src/commands/link/accept.rs +++ b/smartcontract/sdk/rs/src/commands/link/accept.rs @@ -134,6 +134,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; let device_z = doublezero_serviceability::state::device::Device { @@ -235,6 +237,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Requested, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![], + 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..6d65ab36fd 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)); } + // The unicast-default topology account is required; ActivateLink auto-tags the link into it. + let (unicast_default_topology_pda, _) = + get_topology_pda(&client.get_program_id(), "unicast-default"); + accounts.push(AccountMeta::new(unicast_default_topology_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_topology_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,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; // Mock Link fetch @@ -147,6 +159,7 @@ mod tests { AccountMeta::new(side_a_pk, false), AccountMeta::new(side_z_pk, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(unicast_default_topology_pda, false), ]), ) .returning(|_, _| Ok(Signature::new_unique())); @@ -169,6 +182,8 @@ mod tests { let mut client = create_test_client(); let (globalstate_pubkey, _) = get_globalstate_pda(&client.get_program_id()); + let (unicast_default_topology_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 +210,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Pending, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; // Compute ResourceExtension PDAs @@ -224,6 +241,7 @@ mod tests { AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(device_tunnel_block_ext, false), AccountMeta::new(link_ids_ext, false), + AccountMeta::new(unicast_default_topology_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..2539501a3d 100644 --- a/smartcontract/sdk/rs/src/commands/link/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/link/closeaccount.rs @@ -115,6 +115,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![], + link_flags: 0, }; // Mock Link fetch @@ -185,6 +187,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Deleting, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![], + 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..2399777beb 100644 --- a/smartcontract/sdk/rs/src/commands/link/delete.rs +++ b/smartcontract/sdk/rs/src/commands/link/delete.rs @@ -106,6 +106,8 @@ mod tests { jitter_ns: 100000, status: LinkStatus::Activated, desired_status: LinkDesiredStatus::Activated, + link_topologies: vec![], + 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..192f6ee516 100644 --- a/smartcontract/sdk/rs/src/commands/link/update.rs +++ b/smartcontract/sdk/rs/src/commands/link/update.rs @@ -100,6 +100,8 @@ impl UpdateLinkCommand { tunnel_id: self.tunnel_id, tunnel_net: self.tunnel_net, use_onchain_allocation, + link_topologies: None, + unicast_drained: None, }), accounts, ) diff --git a/smartcontract/sdk/rs/src/commands/tenant/delete.rs b/smartcontract/sdk/rs/src/commands/tenant/delete.rs index f4580b0ae1..eed416badb 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/delete.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/delete.rs @@ -250,6 +250,7 @@ mod tests { metro_routing: false, route_liveness: false, billing: TenantBillingConfig::default(), + include_topologies: vec![], }; let mut seq = Sequence::new(); diff --git a/smartcontract/sdk/rs/src/commands/tenant/update.rs b/smartcontract/sdk/rs/src/commands/tenant/update.rs index cd175fed5c..b0b511e37d 100644 --- a/smartcontract/sdk/rs/src/commands/tenant/update.rs +++ b/smartcontract/sdk/rs/src/commands/tenant/update.rs @@ -26,6 +26,7 @@ impl UpdateTenantCommand { metro_routing: self.metro_routing, route_liveness: self.route_liveness, billing: self.billing, + include_topologies: None, }), vec![ AccountMeta::new(self.tenant_pubkey, false), From 87c5a8c2bcb971381dfdb310930ed023e3fca10e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 9 Apr 2026 15:29:56 -0500 Subject: [PATCH 2/5] smartcontract: add MigrateDeviceInterfaces, interface V2 flex_algo support - Add flex_algo_node_segments field to Interface::V2; add deserialize_legacy_v2_interface for reading pre-RFC-18 accounts - Add MigrateDeviceInterfaces processor: rewrites pre-RFC-18 device accounts (no flex_algo bytes) to new V2 layout; idempotent so the activator startup sweep can call it unconditionally; accepts foundation, device owner, or activator authority as signer - Wire MigrateDeviceInterfaces into entrypoint and instructions - Update ActivateLink to pass unicast_default_topology_pda unconditionally; processor tags link only when account is initialized - Update topology backfill/create processors - Remove V3 discriminant from smartcontract Go SDK; DeserializeInterfaceV2 now reads flex_algo_node_segments unconditionally (requires migration) - Register topology commands in Rust SDK - Add integration tests for MigrateDeviceInterfaces: idempotency, authorization (unauthorized/activator/non-signer), legacy account migration --- .../src/entrypoint.rs | 4 + .../src/instructions.rs | 6 + .../processors/device/migrate_interfaces.rs | 232 +++++ .../src/processors/device/mod.rs | 1 + .../src/processors/link/activate.rs | 16 +- .../src/processors/topology/backfill.rs | 6 +- .../src/processors/topology/create.rs | 10 +- .../src/state/interface.rs | 85 +- .../tests/link_wan_test.rs | 22 +- .../tests/migrate_interfaces_test.rs | 824 ++++++++++++++++++ .../sdk/go/serviceability/bytereader_test.go | 8 +- .../sdk/go/serviceability/deserialize.go | 34 +- smartcontract/sdk/go/serviceability/state.go | 12 + smartcontract/sdk/rs/src/commands/mod.rs | 1 + smartcontract/sdk/rs/src/lib.rs | 3 +- 15 files changed, 1188 insertions(+), 76 deletions(-) create mode 100644 smartcontract/programs/doublezero-serviceability/src/processors/device/migrate_interfaces.rs create mode 100644 smartcontract/programs/doublezero-serviceability/tests/migrate_interfaces_test.rs diff --git a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs index 6c33335624..47ebe86435 100644 --- a/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs +++ b/smartcontract/programs/doublezero-serviceability/src/entrypoint.rs @@ -32,6 +32,7 @@ use crate::{ reject::process_reject_device_interface, remove::process_remove_device_interface, unlink::process_unlink_device_interface, update::process_update_device_interface, }, + migrate_interfaces::process_migrate_device_interfaces, reject::process_reject_device, sethealth::process_set_health_device, update::process_update_device, @@ -447,6 +448,9 @@ pub fn process_instruction( DoubleZeroInstruction::BackfillTopology(value) => { process_topology_backfill(program_id, accounts, &value)? } + DoubleZeroInstruction::MigrateDeviceInterfaces(value) => { + process_migrate_device_interfaces(program_id, accounts, &value)? + } }; Ok(()) } diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index ab8d439871..5bf123b64f 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -22,6 +22,7 @@ use crate::processors::{ remove::DeviceInterfaceRemoveArgs, unlink::DeviceInterfaceUnlinkArgs, update::DeviceInterfaceUpdateArgs, }, + migrate_interfaces::MigrateDeviceInterfacesArgs, reject::DeviceRejectArgs, sethealth::DeviceSetHealthArgs, update::DeviceUpdateArgs, @@ -233,6 +234,8 @@ pub enum DoubleZeroInstruction { DeleteTopology(TopologyDeleteArgs), // variant 108 ClearTopology(TopologyClearArgs), // variant 109 BackfillTopology(TopologyBackfillArgs), // variant 110 + + MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs), // variant 111 } impl DoubleZeroInstruction { @@ -373,6 +376,7 @@ impl DoubleZeroInstruction { 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())), + 111 => Ok(Self::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs::try_from(rest).unwrap())), _ => Err(ProgramError::InvalidInstructionData), } @@ -516,6 +520,7 @@ impl DoubleZeroInstruction { Self::DeleteTopology(_) => "DeleteTopology".to_string(), // variant 108 Self::ClearTopology(_) => "ClearTopology".to_string(), // variant 109 Self::BackfillTopology(_) => "BackfillTopology".to_string(), // variant 110 + Self::MigrateDeviceInterfaces(_) => "MigrateDeviceInterfaces".to_string(), // variant 111 } } @@ -651,6 +656,7 @@ impl DoubleZeroInstruction { Self::DeleteTopology(args) => format!("{args:?}"), // variant 108 Self::ClearTopology(args) => format!("{args:?}"), // variant 109 Self::BackfillTopology(args) => format!("{args:?}"), // variant 110 + Self::MigrateDeviceInterfaces(args) => format!("{args:?}"), // variant 111 } } } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/migrate_interfaces.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/migrate_interfaces.rs new file mode 100644 index 0000000000..e83a026b36 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/migrate_interfaces.rs @@ -0,0 +1,232 @@ +use crate::{ + error::DoubleZeroError, + serializer::try_acc_write, + state::{ + accounttype::AccountType, + device::Device, + globalstate::GlobalState, + interface::{deserialize_legacy_v2_interface, Interface}, + }, +}; +use borsh::BorshSerialize; +use borsh_incremental::BorshDeserializeIncremental; +use core::fmt; +#[cfg(test)] +use solana_program::msg; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + program_error::ProgramError, + pubkey::Pubkey, +}; + +#[derive(BorshSerialize, BorshDeserializeIncremental, PartialEq, Clone, Default)] +pub struct MigrateDeviceInterfacesArgs {} + +impl fmt::Debug for MigrateDeviceInterfacesArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "MigrateDeviceInterfacesArgs") + } +} + +/// Deserializes a Device account using the legacy interface format (pre-RFC-18), +/// where Interface discriminant 1 does NOT have trailing flex_algo_node_segments bytes. +/// +/// `data` is advanced past all bytes consumed by this function. If bytes remain +/// after the call, those are flex_algo_node_segments bytes from an already-migrated +/// account (new V2 format). The caller can check `data.is_empty()` to detect this. +/// +/// This is the only place that understands the old wire format; all other code +/// assumes accounts have already been migrated. +fn deserialize_device_legacy(data: &mut &[u8]) -> Result { + let mut reader = *data; + + let account_type: AccountType = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let owner: solana_program::pubkey::Pubkey = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let index: u128 = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let bump_seed: u8 = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let location_pk: solana_program::pubkey::Pubkey = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let exchange_pk: solana_program::pubkey::Pubkey = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let device_type: crate::state::device::DeviceType = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let public_ip: std::net::Ipv4Addr = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or([0, 0, 0, 0].into()); + let status: crate::state::device::DeviceStatus = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let code: String = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let dz_prefixes: doublezero_program_common::types::NetworkV4List = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let metrics_publisher_pk: solana_program::pubkey::Pubkey = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let contributor_pk: solana_program::pubkey::Pubkey = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let mgmt_vrf: String = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + + // Read the interfaces vec using the legacy format. + // Borsh encodes Vec as: [u32 length][T...] — we read the count manually, + // then deserialize each interface using the legacy reader. + let iface_count: u32 = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let mut interfaces: Vec = Vec::with_capacity(iface_count as usize); + for _ in 0..iface_count { + // Read the discriminant byte to determine interface version. + let discriminant: u8 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let iface = match discriminant { + 0 => { + let v1: crate::state::interface::InterfaceV1 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + Interface::V1(v1) + } + 1 | 2 => { + // Legacy V2 format — no flex_algo_node_segments bytes on disk. + let v2 = deserialize_legacy_v2_interface(&mut reader).unwrap_or_default(); + Interface::V2(v2) + } + _ => Interface::V2(crate::state::interface::InterfaceV2::default()), + }; + interfaces.push(iface); + } + + let reference_count: u32 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let users_count: u16 = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let max_users: u16 = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let device_health: crate::state::device::DeviceHealth = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let desired_status: crate::state::device::DeviceDesiredStatus = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let unicast_users_count: u16 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let multicast_subscribers_count: u16 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let max_unicast_users: u16 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let max_multicast_subscribers: u16 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let reserved_seats: u16 = borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let multicast_publishers_count: u16 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + let max_multicast_publishers: u16 = + borsh::BorshDeserialize::deserialize(&mut reader).unwrap_or_default(); + + if account_type != AccountType::Device { + return Err(ProgramError::InvalidAccountData); + } + + // Advance the caller's slice past all consumed bytes so they can check + // whether any bytes remain (indicating an already-migrated account). + *data = reader; + + Ok(Device { + account_type, + owner, + index, + bump_seed, + location_pk, + exchange_pk, + device_type, + public_ip, + status, + code, + dz_prefixes, + metrics_publisher_pk, + contributor_pk, + mgmt_vrf, + interfaces, + reference_count, + users_count, + max_users, + device_health, + desired_status, + unicast_users_count, + multicast_subscribers_count, + max_unicast_users, + max_multicast_subscribers, + reserved_seats, + multicast_publishers_count, + max_multicast_publishers, + }) +} + +/// Migrates a Device account's interfaces from the pre-RFC-18 on-chain format +/// (Interface discriminant 1, no flex_algo_node_segments bytes) to the current +/// format (Interface discriminant 1 with an empty flex_algo_node_segments vec). +/// +/// This instruction is idempotent: calling it on an already-migrated account is +/// a no-op. This is safe for the activator startup sweep, which calls it for all +/// devices without knowing which ones are already in the new format. +/// +/// Accounts expected: +/// 0. `device_account` — writable, owned by this program +/// 1. `globalstate_account` — readable, used for authorization +/// 2. `payer_account` — signer (foundation, device owner, or activator authority) +/// 3. `system_program` — for account resizing +pub fn process_migrate_device_interfaces( + program_id: &Pubkey, + accounts: &[AccountInfo], + _value: &MigrateDeviceInterfacesArgs, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let device_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)?; + + #[cfg(test)] + msg!("process_migrate_device_interfaces"); + + if !payer_account.is_signer { + return Err(DoubleZeroError::NotAllowed.into()); + } + + if device_account.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + if globalstate_account.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + let globalstate = GlobalState::try_from(globalstate_account)?; + + // Read the device using the legacy deserializer. The borrow and the data_slice + // derived from it are scoped to this block so the immutable borrow on + // device_account.data is released before try_acc_write takes a mutable borrow. + let (device, already_migrated) = { + let data_borrow = device_account.data.borrow(); + let mut data_slice: &[u8] = &data_borrow; + let device = deserialize_device_legacy(&mut data_slice)?; + // If bytes remain after the legacy deserialization, those are + // flex_algo_node_segments bytes — the account is already in new V2 format. + let already_migrated = !data_slice.is_empty(); + (device, already_migrated) + }; + + // Authorization: payer must be the foundation, the device owner, or the activator + // authority. The activator calls this during its startup sweep. + let is_foundation = globalstate.foundation_allowlist.contains(payer_account.key); + let is_owner = device.owner == *payer_account.key; + let is_activator = globalstate.activator_authority_pk == *payer_account.key; + if !is_foundation && !is_owner && !is_activator { + return Err(DoubleZeroError::NotAllowed.into()); + } + + // Idempotency check: the account is already in new V2 format — skip migration + // so we don't zero any topology assignments in the flex_algo_node_segments vecs. + if already_migrated { + return Ok(()); + } + + // Write back with the current format — each interface now includes the + // (empty) flex_algo_node_segments vec in its serialized form. + try_acc_write(&device, device_account, payer_account, accounts)?; + + #[cfg(test)] + msg!("Migrated device: {}", device.code); + + Ok(()) +} diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/mod.rs index f540b5d6d9..5e7d7cd6b5 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/mod.rs @@ -3,6 +3,7 @@ pub mod closeaccount; pub mod create; pub mod delete; pub mod interface; +pub mod migrate_interfaces; pub mod reject; pub mod sethealth; pub mod update; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs index ec442d6778..981b9c3c72 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/link/activate.rs @@ -232,15 +232,19 @@ pub fn process_activate_link( link.check_status_transition(); - // Auto-tag with UNICAST-DEFAULT topology at activation + // Auto-tag with UNICAST-DEFAULT topology at activation. + // Always validate the PDA derivation to prevent callers passing a wrong account. + // Tagging is conditional: if the topology hasn't been created yet (e.g. fresh deployment), + // activation proceeds without the tag rather than failing. 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() - { + if unicast_default_topology_account.key != &expected_unicast_default_pda { return Err(DoubleZeroError::InvalidArgument.into()); } - link.link_topologies = vec![*unicast_default_topology_account.key]; + if unicast_default_topology_account.owner == program_id + && !unicast_default_topology_account.data_is_empty() + { + 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)?; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs index b9bd9fa392..4bef8b0176 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/backfill.rs @@ -141,8 +141,8 @@ pub fn process_topology_backfill( } let node_segment_idx = allocate_id(segment_routing_ids_account)?; match iface { - Interface::V3(ref mut v3) => { - v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_key, node_segment_idx, }); @@ -153,7 +153,7 @@ pub fn process_topology_backfill( topology: *topology_key, node_segment_idx, }); - *iface = Interface::V3(upgraded); + *iface = Interface::V2(upgraded); } } modified = true; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs index ed3fec4f3e..d132c2f306 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/topology/create.rs @@ -162,22 +162,22 @@ pub fn process_topology_create( continue; } let node_segment_idx = allocate_id(segment_routing_ids_account)?; - // Mutate the interface in place — upgrade to V3 if needed + // Mutate the interface in place match iface { - Interface::V3(ref mut v3) => { - v3.flex_algo_node_segments.push(FlexAlgoNodeSegment { + Interface::V2(ref mut v2) => { + v2.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_account.key, node_segment_idx, }); } _ => { - // Upgrade to current version (V3) with the segment added + // Upgrade to current version with the segment added let mut upgraded = iface.into_current_version(); upgraded.flex_algo_node_segments.push(FlexAlgoNodeSegment { topology: *topology_account.key, node_segment_idx, }); - *iface = Interface::V3(upgraded); + *iface = Interface::V2(upgraded); } } modified = true; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index d6a09823ae..1bba7fab02 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -317,7 +317,7 @@ impl InterfaceV2 { } pub fn to_interface(&self) -> Interface { - Interface::V3(self.clone()) + Interface::V2(self.clone()) } pub fn size_given_name_len(name_len: usize) -> usize { @@ -407,12 +407,12 @@ impl Default for InterfaceV2 { #[borsh(use_discriminant = true)] pub enum Interface { V1(InterfaceV1) = 0, - /// Discriminant 1: old on-chain V2 format. Does NOT include - /// flex_algo_node_segments bytes. Read-only — new accounts use V3. + /// Discriminant 1: current format. Includes flex_algo_node_segments. + /// New accounts are written with this discriminant. Old on-chain accounts + /// (written before flex_algo_node_segments existed) are upgraded in-place + /// by the MigrateDeviceInterfaces instruction before this deserialization + /// path is used. V2(InterfaceV2) = 1, - /// Discriminant 3: current format. Includes flex_algo_node_segments. - /// Discriminant 2 is intentionally skipped (reserved for future use). - V3(InterfaceV2) = 3, } impl borsh::BorshDeserialize for Interface { @@ -422,40 +422,15 @@ impl borsh::BorshDeserialize for Interface { 0 => Ok(Interface::V1(borsh::BorshDeserialize::deserialize_reader( reader, )?)), - 1 | 2 => { - // Old on-chain V2 format. flex_algo_node_segments is NOT present - // in these accounts — reading it would consume bytes from the next - // field in the parent buffer and corrupt deserialization. - Ok(Interface::V2(InterfaceV2 { - status: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), - name: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), - interface_type: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - interface_cyoa: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - interface_dia: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - loopback_type: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - bandwidth: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - cir: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), - mtu: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), - routing_mode: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - vlan_id: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - ip_net: borsh::BorshDeserialize::deserialize_reader(reader).unwrap_or_default(), - node_segment_idx: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - user_tunnel_endpoint: borsh::BorshDeserialize::deserialize_reader(reader) - .unwrap_or_default(), - flex_algo_node_segments: vec![], - })) + 1 => { + // Current format — includes flex_algo_node_segments. Assumes the + // account has been migrated by MigrateDeviceInterfaces before this + // path is reached (old pre-migration accounts used this same discriminant + // but lacked the trailing flex_algo bytes). + Ok(Interface::V2(borsh::BorshDeserialize::deserialize_reader( + reader, + )?)) } - 3 => Ok(Interface::V3(borsh::BorshDeserialize::deserialize_reader( - reader, - )?)), _ => Ok(Interface::V2(InterfaceV2::default())), } } @@ -467,14 +442,14 @@ impl Interface { pub fn into_current_version(&self) -> CurrentInterfaceVersion { match self { Interface::V1(v1) => v1.try_into().unwrap_or_default(), - Interface::V2(v2) | Interface::V3(v2) => v2.clone(), + Interface::V2(v2) => v2.clone(), } } pub fn size(&self) -> usize { let base_size = match self { Interface::V1(v1) => v1.size(), - Interface::V2(v2) | Interface::V3(v2) => v2.size(), + Interface::V2(v2) => v2.size(), }; base_size + 1 // +1 for the enum discriminant } @@ -541,6 +516,30 @@ impl TryFrom<&[u8]> for Interface { } } +/// Reads a V2 interface in the pre-RFC-18 format (no flex_algo_node_segments bytes). +/// Used exclusively by MigrateDeviceInterfaces to read old on-chain accounts. +pub fn deserialize_legacy_v2_interface( + reader: &mut R, +) -> borsh::io::Result { + Ok(InterfaceV2 { + status: borsh::BorshDeserialize::deserialize_reader(reader)?, + name: borsh::BorshDeserialize::deserialize_reader(reader)?, + interface_type: borsh::BorshDeserialize::deserialize_reader(reader)?, + interface_cyoa: borsh::BorshDeserialize::deserialize_reader(reader)?, + interface_dia: borsh::BorshDeserialize::deserialize_reader(reader)?, + loopback_type: borsh::BorshDeserialize::deserialize_reader(reader)?, + bandwidth: borsh::BorshDeserialize::deserialize_reader(reader)?, + cir: borsh::BorshDeserialize::deserialize_reader(reader)?, + mtu: borsh::BorshDeserialize::deserialize_reader(reader)?, + routing_mode: borsh::BorshDeserialize::deserialize_reader(reader)?, + vlan_id: borsh::BorshDeserialize::deserialize_reader(reader)?, + ip_net: borsh::BorshDeserialize::deserialize_reader(reader)?, + node_segment_idx: borsh::BorshDeserialize::deserialize_reader(reader)?, + user_tunnel_endpoint: borsh::BorshDeserialize::deserialize_reader(reader)?, + flex_algo_node_segments: vec![], + }) +} + #[test] fn test_interface_version() { let iface = InterfaceV1 { @@ -588,8 +587,8 @@ fn test_interface_version() { .to_interface(); assert!( - matches!(iface, Interface::V3(_)), - "iface is not Interface::V3" + matches!(iface, Interface::V2(_)), + "iface is not Interface::V2" ); let iface_v2: CurrentInterfaceVersion = iface.into_current_version(); assert_eq!(iface_v2.name, "Loopback0"); diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index 428d010a52..bc5f1e6c98 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -2671,7 +2671,9 @@ async fn test_link_activation_auto_tags_unicast_default() { } #[tokio::test] -async fn test_link_activation_fails_without_unicast_default() { +async fn test_link_activation_succeeds_without_unicast_default() { + // Activation must succeed even when the unicast-default topology hasn't been created yet + // (e.g. fresh deployment before topology creation). The link simply has no topology tags. let ( mut banks_client, program_id, @@ -2691,9 +2693,7 @@ async fn test_link_activation_fails_without_unicast_default() { // Derive the unicast-default PDA without creating it let (unicast_default_pda, _) = get_topology_pda(&program_id, "unicast-default"); - // Attempt to activate — should fail because the unicast-default account is - // system-owned (not created), triggering the owner check before key validation. - let result = try_execute_transaction( + execute_transaction( &mut banks_client, recent_blockhash, program_id, @@ -2713,11 +2713,17 @@ async fn test_link_activation_fails_without_unicast_default() { ) .await; - let error_string = format!("{:?}", result.unwrap_err()); + // link_topologies should be empty since the topology account was not initialized + 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!( - error_string.contains("Custom(65)"), - "Expected InvalidArgument error (Custom(65)), got: {}", - error_string + link.link_topologies.is_empty(), + "link_topologies should be empty when unicast-default has not been created, got: {:?}", + link.link_topologies ); } diff --git a/smartcontract/programs/doublezero-serviceability/tests/migrate_interfaces_test.rs b/smartcontract/programs/doublezero-serviceability/tests/migrate_interfaces_test.rs new file mode 100644 index 0000000000..3b8f72fd91 --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/migrate_interfaces_test.rs @@ -0,0 +1,824 @@ +use borsh::to_vec; +use doublezero_serviceability::{ + instructions::*, + pda::*, + processors::{ + contributor::create::ContributorCreateArgs, + device::{create::*, migrate_interfaces::MigrateDeviceInterfacesArgs}, + globalconfig::set::SetGlobalConfigArgs, + *, + }, + resource::ResourceType, + state::{ + accounttype::AccountType, + contributor::{Contributor, ContributorStatus}, + device::*, + globalstate::GlobalState, + interface::{ + InterfaceCYOA, InterfaceDIA, InterfaceStatus, InterfaceType, InterfaceV2, LoopbackType, + RoutingMode, + }, + }, +}; +use solana_program_test::*; +use solana_sdk::{ + account::Account, + instruction::{AccountMeta, Instruction, InstructionError}, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod test_helpers; +use test_helpers::*; + +// --------------------------------------------------------------------------- +// Shared setup: InitGlobalState + SetGlobalConfig + Location + Exchange + +// Contributor + Device. Returns the pubkeys needed for MigrateDeviceInterfaces. +// --------------------------------------------------------------------------- +async fn setup_with_device( + banks_client: &mut BanksClient, + program_id: Pubkey, + payer: &Keypair, +) -> (Pubkey, Pubkey) { + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + let (program_config_pubkey, _) = get_program_config_pda(&program_id); + let (globalstate_pubkey, _) = get_globalstate_pda(&program_id); + let (config_pubkey, _) = get_globalconfig_pda(&program_id); + let (device_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DeviceTunnelBlock); + let (user_tunnel_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::UserTunnelBlock); + let (multicastgroup_block_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::MulticastGroupBlock); + let (link_ids_pda, _, _) = get_resource_extension_pda(&program_id, ResourceType::LinkIds); + let (segment_routing_ids_pda, _, _) = + get_resource_extension_pda(&program_id, ResourceType::SegmentRoutingIds); + 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( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::InitGlobalState(), + vec![ + AccountMeta::new(program_config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::SetGlobalConfig(SetGlobalConfigArgs { + local_asn: 65000, + remote_asn: 65001, + device_tunnel_block: "10.0.0.0/24".parse().unwrap(), + user_tunnel_block: "10.0.0.0/24".parse().unwrap(), + multicastgroup_block: "224.0.0.0/16".parse().unwrap(), + multicast_publisher_block: "148.51.120.0/21".parse().unwrap(), + next_bgp_community: None, + }), + vec![ + AccountMeta::new(config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(device_tunnel_block_pda, false), + AccountMeta::new(user_tunnel_block_pda, false), + AccountMeta::new(multicastgroup_block_pda, false), + AccountMeta::new(link_ids_pda, false), + 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, + ) + .await; + + // Location + let gs = get_globalstate(banks_client, globalstate_pubkey).await; + let (location_pubkey, _) = get_location_pda(&program_id, gs.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLocation(location::create::LocationCreateArgs { + code: "test".to_string(), + name: "Test Location".to_string(), + country: "us".to_string(), + lat: 0.0, + lng: 0.0, + loc_id: 0, + }), + vec![ + AccountMeta::new(location_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Exchange + let gs = get_globalstate(banks_client, globalstate_pubkey).await; + let (exchange_pubkey, _) = get_exchange_pda(&program_id, gs.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateExchange(exchange::create::ExchangeCreateArgs { + code: "test".to_string(), + name: "Test Exchange".to_string(), + lat: 0.0, + lng: 0.0, + reserved: 0, + }), + vec![ + AccountMeta::new(exchange_pubkey, false), + AccountMeta::new(config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Contributor + let gs = get_globalstate(banks_client, globalstate_pubkey).await; + let (contributor_pubkey, _) = get_contributor_pda(&program_id, gs.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateContributor(ContributorCreateArgs { + code: "testco".to_string(), + }), + vec![ + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(payer.pubkey(), false), + AccountMeta::new(globalstate_pubkey, false), + ], + payer, + ) + .await; + + // Device + let gs = get_globalstate(banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, gs.account_index + 1); + execute_transaction( + banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "testdev".to_string(), + device_type: DeviceType::Hybrid, + public_ip: [100, 0, 0, 1].into(), + dz_prefixes: "100.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; + + (device_pubkey, globalstate_pubkey) +} + +// --------------------------------------------------------------------------- +// test_migrate_device_interfaces_idempotent +// +// Newly created devices are already in the new V2 format (with +// flex_algo_node_segments bytes). The first call succeeds via the idempotency +// path; the second call also succeeds, and the raw account bytes are identical +// after both calls. +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_migrate_device_interfaces_idempotent() { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(doublezero_serviceability::entrypoint::process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, funder, _recent_blockhash) = program_test.start().await; + + let payer = test_payer(); + transfer(&mut banks_client, &funder, &payer.pubkey(), 10_000_000_000).await; + + let (device_pubkey, globalstate_pubkey) = + setup_with_device(&mut banks_client, program_id, &payer).await; + + // Read raw bytes before any migration call. + let before = banks_client + .get_account(device_pubkey) + .await + .unwrap() + .unwrap() + .data + .clone(); + + // First call — should succeed (idempotency path: already new V2 format). + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs {}), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + assert!( + result.is_ok(), + "First MigrateDeviceInterfaces call should succeed: {result:?}" + ); + + let after_first = banks_client + .get_account(device_pubkey) + .await + .unwrap() + .unwrap() + .data + .clone(); + + // Account data must not change (no rewrite for already-migrated accounts). + assert_eq!( + before, after_first, + "Account bytes must be unchanged after idempotent first call" + ); + + // Second call — also succeeds (idempotency). + let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); + let result2 = try_execute_transaction( + &mut banks_client, + recent_blockhash2, + program_id, + DoubleZeroInstruction::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs {}), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + assert!( + result2.is_ok(), + "Second MigrateDeviceInterfaces call should succeed: {result2:?}" + ); + + let after_second = banks_client + .get_account(device_pubkey) + .await + .unwrap() + .unwrap() + .data + .clone(); + + assert_eq!( + after_first, after_second, + "Account bytes must be identical after both idempotent calls" + ); +} + +// --------------------------------------------------------------------------- +// test_migrate_device_interfaces_unauthorized +// +// A keypair that is neither the foundation, the device owner, nor the activator +// authority must be rejected with NotAllowed (error code 8). +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_migrate_device_interfaces_unauthorized() { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(doublezero_serviceability::entrypoint::process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, funder, _recent_blockhash) = program_test.start().await; + + let payer = test_payer(); + transfer(&mut banks_client, &funder, &payer.pubkey(), 10_000_000_000).await; + + let (device_pubkey, globalstate_pubkey) = + setup_with_device(&mut banks_client, program_id, &payer).await; + + // Fund an unauthorized keypair. + let unauthorized = Keypair::new(); + transfer( + &mut banks_client, + &funder, + &unauthorized.pubkey(), + 10_000_000, + ) + .await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs {}), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &unauthorized, + ) + .await; + + // NotAllowed is Custom(8). + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed (Custom(8)), got: {result:?}"), + } +} + +// --------------------------------------------------------------------------- +// test_migrate_device_interfaces_activator_authority +// +// Inject a GlobalState with activator_authority_pk set to a known keypair, +// then call MigrateDeviceInterfaces signed by that keypair. Expect success. +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_migrate_device_interfaces_activator_authority() { + let activator = Keypair::new(); + let payer = test_payer(); + + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(doublezero_serviceability::entrypoint::process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + + // Build accounts from scratch so we can inject activator_authority_pk + // without needing an onchain instruction to set it. + let (globalstate_pubkey, gs_bump) = get_globalstate_pda(&program_id); + let (contributor_pubkey, co_bump) = get_contributor_pda(&program_id, 1); + let (device_pubkey, dev_bump) = get_device_pda(&program_id, 2); + + let globalstate = GlobalState { + account_type: AccountType::GlobalState, + bump_seed: gs_bump, + account_index: 2, + foundation_allowlist: vec![payer.pubkey()], + activator_authority_pk: activator.pubkey(), + ..Default::default() + }; + let gs_data = borsh::to_vec(&globalstate).unwrap(); + program_test.add_account( + globalstate_pubkey, + Account { + lamports: 1_000_000_000, + data: gs_data, + owner: program_id, + ..Account::default() + }, + ); + + let contributor = Contributor { + account_type: AccountType::Contributor, + owner: payer.pubkey(), + index: 1, + bump_seed: co_bump, + status: ContributorStatus::Activated, + code: "testco".to_string(), + reference_count: 1, + ops_manager_pk: Pubkey::default(), + }; + let co_data = borsh::to_vec(&contributor).unwrap(); + program_test.add_account( + contributor_pubkey, + Account { + lamports: 1_000_000_000, + data: co_data, + owner: program_id, + ..Account::default() + }, + ); + + // A freshly serialized device is already in V2 format (has flex_algo_node_segments + // bytes), so the idempotency check will trigger. That is fine: the test only + // validates that the activator authority key is accepted. + let device = Device { + account_type: AccountType::Device, + owner: payer.pubkey(), + index: 2, + bump_seed: dev_bump, + location_pk: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + device_type: DeviceType::Hybrid, + public_ip: [100, 0, 0, 1].into(), + status: DeviceStatus::Pending, + code: "testdev".to_string(), + dz_prefixes: vec!["100.1.0.0/23".parse().unwrap()].into(), + metrics_publisher_pk: Pubkey::default(), + contributor_pk: contributor_pubkey, + mgmt_vrf: "mgmt".to_string(), + interfaces: vec![], + reference_count: 0, + users_count: 0, + max_users: 128, + device_health: DeviceHealth::ReadyForUsers, + desired_status: DeviceDesiredStatus::Activated, + unicast_users_count: 0, + multicast_subscribers_count: 0, + max_unicast_users: 0, + max_multicast_subscribers: 0, + reserved_seats: 0, + multicast_publishers_count: 0, + max_multicast_publishers: 0, + }; + let dev_data = borsh::to_vec(&device).unwrap(); + program_test.add_account( + device_pubkey, + Account { + lamports: 1_000_000_000, + data: dev_data, + owner: program_id, + ..Account::default() + }, + ); + + let (mut banks_client, funder, _) = program_test.start().await; + transfer(&mut banks_client, &funder, &payer.pubkey(), 100_000_000).await; + transfer(&mut banks_client, &funder, &activator.pubkey(), 10_000_000).await; + + // Call MigrateDeviceInterfaces signed by the activator authority. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs {}), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &activator, + ) + .await; + + assert!( + result.is_ok(), + "Activator authority should be allowed to call MigrateDeviceInterfaces: {result:?}" + ); +} + +// --------------------------------------------------------------------------- +// test_migrate_device_interfaces_non_signer +// +// The payer account is included in the instruction accounts but is not marked +// as a signer (is_signer = false). The program checks `payer_account.is_signer` +// first and must return NotAllowed (Custom(8)). +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_migrate_device_interfaces_non_signer() { + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(doublezero_serviceability::entrypoint::process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + let (mut banks_client, funder, _recent_blockhash) = program_test.start().await; + + let payer = test_payer(); + transfer(&mut banks_client, &funder, &payer.pubkey(), 10_000_000_000).await; + + let (device_pubkey, globalstate_pubkey) = + setup_with_device(&mut banks_client, program_id, &payer).await; + + // Build the instruction manually so the payer is listed as account[2] but + // is NOT marked is_signer = true. Only the transaction fee payer signs. + let non_signer_pubkey = Pubkey::new_unique(); + let instruction = + DoubleZeroInstruction::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs {}); + let ix = Instruction::new_with_bytes( + program_id, + &to_vec(&instruction).unwrap(), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + AccountMeta::new(non_signer_pubkey, false), // payer slot — NOT a signer + AccountMeta::new(solana_system_interface::program::ID, false), + ], + ); + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let mut tx = Transaction::new_with_payer(&[ix], Some(&payer.pubkey())); + tx.sign(&[&payer], recent_blockhash); + let result = banks_client.process_transaction(tx).await; + + // NotAllowed is Custom(8). + match result { + Err(BanksClientError::TransactionError(TransactionError::InstructionError( + 0, + InstructionError::Custom(8), + ))) => {} + _ => panic!("Expected NotAllowed (Custom(8)), got: {result:?}"), + } +} + +// --------------------------------------------------------------------------- +// test_migrate_device_interfaces_legacy_account +// +// This test exercises the actual migration code path (legacy → new V2 format). +// +// Approach: +// 1. Serialize a Device with V2 interfaces (new format, with flex_algo_node_segments). +// 2. Strip the 4-byte empty-vec length prefix that Borsh appends for each +// flex_algo_node_segments field from the end of each interface's bytes. +// The V2 interface on-disk layout (excluding discriminant) is: +// status(1) + name(4+n) + interface_type(1) + cyoa(1) + dia(1) + +// loopback_type(1) + bandwidth(8) + cir(8) + mtu(2) + routing_mode(1) + +// vlan_id(2) + ip_net(5) + node_segment_idx(2) + user_tunnel_endpoint(1) + +// flex_algo_node_segments_len(4) ← the 4 bytes to strip for legacy +// 3. Inject the truncated bytes as the device account data. +// 4. Call MigrateDeviceInterfaces. +// 5. Verify the account data has grown by 4 bytes per interface (the +// flex_algo_node_segments vec length prefix has been written back). +// --------------------------------------------------------------------------- +#[tokio::test] +async fn test_migrate_device_interfaces_legacy_account() { + let payer = test_payer(); + + let program_id = Pubkey::new_unique(); + let mut program_test = ProgramTest::new( + "doublezero_serviceability", + program_id, + processor!(doublezero_serviceability::entrypoint::process_instruction), + ); + program_test.set_compute_max_units(1_000_000); + + let (globalstate_pubkey, gs_bump) = get_globalstate_pda(&program_id); + let (contributor_pubkey, co_bump) = get_contributor_pda(&program_id, 1); + let (device_pubkey, dev_bump) = get_device_pda(&program_id, 2); + + // GlobalState with payer as foundation member. + let globalstate = GlobalState { + account_type: AccountType::GlobalState, + bump_seed: gs_bump, + account_index: 2, + foundation_allowlist: vec![payer.pubkey()], + ..Default::default() + }; + let gs_data = borsh::to_vec(&globalstate).unwrap(); + program_test.add_account( + globalstate_pubkey, + Account { + lamports: 1_000_000_000, + data: gs_data, + owner: program_id, + ..Account::default() + }, + ); + + let contributor = Contributor { + account_type: AccountType::Contributor, + owner: payer.pubkey(), + index: 1, + bump_seed: co_bump, + status: ContributorStatus::Activated, + code: "testco".to_string(), + reference_count: 1, + ops_manager_pk: Pubkey::default(), + }; + let co_data = borsh::to_vec(&contributor).unwrap(); + program_test.add_account( + contributor_pubkey, + Account { + lamports: 1_000_000_000, + data: co_data, + owner: program_id, + ..Account::default() + }, + ); + + // Build a device with one V2 interface that has an empty flex_algo_node_segments vec. + let iface = InterfaceV2 { + status: InterfaceStatus::Pending, + name: "Ethernet1".to_string(), + interface_type: InterfaceType::Physical, + interface_cyoa: InterfaceCYOA::None, + interface_dia: InterfaceDIA::None, + loopback_type: LoopbackType::None, + bandwidth: 1000, + cir: 500, + mtu: 9000, + routing_mode: RoutingMode::Static, + vlan_id: 0, + ip_net: "192.168.1.0/24".parse().unwrap(), + node_segment_idx: 0, + user_tunnel_endpoint: false, + flex_algo_node_segments: vec![], + }; + let device = Device { + account_type: AccountType::Device, + owner: payer.pubkey(), + index: 2, + bump_seed: dev_bump, + location_pk: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + device_type: DeviceType::Hybrid, + public_ip: [100, 0, 0, 1].into(), + status: DeviceStatus::Pending, + code: "testdev".to_string(), + dz_prefixes: vec!["100.1.0.0/23".parse().unwrap()].into(), + metrics_publisher_pk: Pubkey::default(), + contributor_pk: contributor_pubkey, + mgmt_vrf: "mgmt".to_string(), + interfaces: vec![iface.to_interface()], + reference_count: 0, + users_count: 0, + max_users: 128, + device_health: DeviceHealth::ReadyForUsers, + desired_status: DeviceDesiredStatus::Activated, + unicast_users_count: 0, + multicast_subscribers_count: 0, + max_unicast_users: 0, + max_multicast_subscribers: 0, + reserved_seats: 0, + multicast_publishers_count: 0, + max_multicast_publishers: 0, + }; + + // Build the legacy bytes by removing the 4-byte flex_algo_node_segments vec + // length prefix from within the interface block. Borsh encodes Vec as a + // u32 length prefix followed by the elements; an empty vec is just [0,0,0,0]. + // + // We locate the flex_algo bytes by computing the sizes of all components that + // precede them: + // • device header (everything up to and including the interfaces vec count) + // • interface discriminant (1 byte) + // • all V2 interface fields that appear before flex_algo_node_segments + // + // Device header bytes before the interfaces vec (4-byte count prefix): + // account_type(1) + owner(32) + index(16) + bump_seed(1) + + // location_pk(32) + exchange_pk(32) + device_type(1) + public_ip(4) + + // status(1) + code len(4+7=11) + dz_prefixes (4+5=9) + + // metrics_publisher_pk(32) + contributor_pk(32) + mgmt_vrf(4+4=8) + // = 212 bytes header + 4 bytes vec count = 216 bytes to start of first interface. + // + // V2 interface fields before flex_algo_node_segments (after discriminant): + // status(1) + name len+data(4+9=13) + interface_type(1) + cyoa(1) + + // dia(1) + loopback_type(1) + bandwidth(8) + cir(8) + mtu(2) + + // routing_mode(1) + vlan_id(2) + ip_net(5) + node_segment_idx(2) + + // user_tunnel_endpoint(1) = 47 bytes. + // + // So flex_algo bytes start at: 216 + 1(discriminant) + 47 = 264. + let new_bytes = borsh::to_vec(&device).unwrap(); + let flex_algo_offset = 264usize; + // Verify that the 4 bytes at that offset are the empty-vec prefix [0,0,0,0]. + assert_eq!( + &new_bytes[flex_algo_offset..flex_algo_offset + 4], + &[0u8, 0, 0, 0], + "Expected flex_algo_node_segments empty vec at byte offset {flex_algo_offset}" + ); + // Build legacy bytes by omitting those 4 bytes. + let mut legacy_bytes = new_bytes[..flex_algo_offset].to_vec(); + legacy_bytes.extend_from_slice(&new_bytes[flex_algo_offset + 4..]); + + program_test.add_account( + device_pubkey, + Account { + lamports: 1_000_000_000, + data: legacy_bytes.clone(), + owner: program_id, + ..Account::default() + }, + ); + + let (mut banks_client, funder, _) = program_test.start().await; + transfer(&mut banks_client, &funder, &payer.pubkey(), 100_000_000).await; + + // Before migration: raw bytes are the shorter legacy form. + let bytes_before = banks_client + .get_account(device_pubkey) + .await + .unwrap() + .unwrap() + .data + .clone(); + assert_eq!( + bytes_before.len(), + legacy_bytes.len(), + "Pre-migration byte length should match injected legacy data" + ); + + // Call MigrateDeviceInterfaces. + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + let result = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs {}), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + assert!( + result.is_ok(), + "MigrateDeviceInterfaces on legacy account should succeed: {result:?}" + ); + + // After migration: the account must have grown by exactly 4 bytes (one interface × + // 4-byte empty-vec prefix). Solana may zero-pad accounts to alignment boundaries, + // so we allow bytes_after.len() >= new_bytes.len(). + let bytes_after = banks_client + .get_account(device_pubkey) + .await + .unwrap() + .unwrap() + .data + .clone(); + + assert!( + bytes_after.len() >= new_bytes.len(), + "Post-migration account ({} bytes) must be at least as large as canonical new format ({} bytes)", + bytes_after.len(), + new_bytes.len() + ); + + // The first new_bytes.len() bytes must match the canonical new V2 serialization. + assert_eq!( + &bytes_after[..new_bytes.len()], + &new_bytes[..], + "Migrated account prefix must match the canonical new V2 serialization" + ); + + // The account must be deserializable as a Device in the new format and the + // interface must carry an empty flex_algo_node_segments vec. + let migrated_device = + Device::try_from(&bytes_after[..]).expect("Failed to deserialize migrated device"); + assert_eq!(migrated_device.code, device.code); + assert_eq!( + migrated_device.interfaces.len(), + 1, + "Interface count must be preserved after migration" + ); + let migrated_iface = migrated_device.interfaces[0].into_current_version(); + assert_eq!( + migrated_iface.flex_algo_node_segments.len(), + 0, + "Migrated interface must have an empty flex_algo_node_segments vec" + ); + + // Calling again must be idempotent: data unchanged. + let recent_blockhash2 = banks_client.get_latest_blockhash().await.unwrap(); + let result2 = try_execute_transaction( + &mut banks_client, + recent_blockhash2, + program_id, + DoubleZeroInstruction::MigrateDeviceInterfaces(MigrateDeviceInterfacesArgs {}), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new_readonly(globalstate_pubkey, false), + ], + &payer, + ) + .await; + assert!( + result2.is_ok(), + "Second call after migration should also succeed" + ); + + let bytes_after2 = banks_client + .get_account(device_pubkey) + .await + .unwrap() + .unwrap() + .data + .clone(); + assert_eq!( + bytes_after, bytes_after2, + "Second call must not change account bytes" + ); +} diff --git a/smartcontract/sdk/go/serviceability/bytereader_test.go b/smartcontract/sdk/go/serviceability/bytereader_test.go index eb0f4919e2..44dce1847a 100644 --- a/smartcontract/sdk/go/serviceability/bytereader_test.go +++ b/smartcontract/sdk/go/serviceability/bytereader_test.go @@ -258,8 +258,8 @@ func TestReadString(t *testing.T) { func TestDeserializeInterfaceV2CrossLanguage(t *testing.T) { t.Parallel() - // These bytes are ACTUAL output from Rust test: - // Hex: [01, 03, 0b, 00, 00, 00, 4c, 6f, 6f, 70, 62, 61, 63, 6b, 31, 30, 36, 01, 00, 00, 02, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 28, 23, 00, 00, 00, cb, 00, 71, 28, 20, 00, 00, 01] + // These bytes are ACTUAL output from Rust test (RFC-18, V2 with flex_algo_node_segments): + // Hex: [01, 03, 0b, 00, 00, 00, 4c, 6f, 6f, 70, 62, 61, 63, 6b, 31, 30, 36, 01, 00, 00, 02, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 00, 28, 23, 00, 00, 00, cb, 00, 71, 28, 20, 00, 00, 01, 00, 00, 00, 00] // // Field breakdown from Rust: // [0] enum discriminant (V2=1): 01 @@ -278,6 +278,7 @@ func TestDeserializeInterfaceV2CrossLanguage(t *testing.T) { // [42-46] ip_net: [cb, 00, 71, 28, 20] = 203.0.113.40/32 // [47-48] node_segment_idx: 0 // [49] user_tunnel_endpoint: 01 (true) + // [50-53] flex_algo_node_segments length: 0 (empty vec) // Use EXACT bytes from Rust serialization data := []byte{ @@ -296,7 +297,8 @@ func TestDeserializeInterfaceV2CrossLanguage(t *testing.T) { 0x00, 0x00, // [40-41] vlan_id=0 0xcb, 0x00, 0x71, 0x28, 0x20, // [42-46] ip_net 203.0.113.40/32 0x00, 0x00, // [47-48] node_segment_idx=0 - 0x01, // [49] user_tunnel_endpoint=true + 0x01, // [49] user_tunnel_endpoint=true + 0x00, 0x00, 0x00, 0x00, // [50-53] flex_algo_node_segments length=0 (empty vec, RFC-18) } t.Logf("Test data (%d bytes): %s", len(data), hex.EncodeToString(data)) diff --git a/smartcontract/sdk/go/serviceability/deserialize.go b/smartcontract/sdk/go/serviceability/deserialize.go index ed0ee87bfe..75287915da 100644 --- a/smartcontract/sdk/go/serviceability/deserialize.go +++ b/smartcontract/sdk/go/serviceability/deserialize.go @@ -80,19 +80,32 @@ func DeserializeContributor(reader *ByteReader, contributor *Contributor) { contributor.OpsManagerPK = reader.ReadPubkey() } +// DeserializeInterface reads an on-chain Interface account from reader. +// +// Interface version history (discriminant byte): +// +// 0 — V1: original format (no CYOA/DIA/Bandwidth fields) +// 1 — V2: adds CYOA, DIA, Bandwidth, Cir, Mtu, RoutingMode, and flex_algo_node_segments (RFC-18) +// Pre-RFC-18 mainnet accounts also use discriminant 1 but lack the flex_algo bytes. +// MigrateDeviceInterfaces must be run on all existing accounts before this SDK is +// deployed, so that every V2 account on-chain has the flex_algo bytes present. +// 2 — reserved, never written +// +// Design note: discriminant 3 (V3) was considered during RFC-18 implementation to distinguish +// old V2 (no flex_algo bytes) from new V2 (with flex_algo bytes) in a shared Borsh buffer +// reader. It was rejected in favour of a one-time migration (MigrateDeviceInterfaces) that +// rewrites all pre-RFC-18 accounts into the new V2 layout, avoiding the need for a new +// discriminant value entirely. func DeserializeInterface(reader *ByteReader, iface *Interface) { iface.Version = reader.ReadU8() - if iface.Version > (CurrentInterfaceVersion - 1) { // subtract 1 because the discriminant starts from 0 - log.Println("DeserializeInterface: Unsupported interface version", iface.Version) - return - } - switch iface.Version { - case 0: // version 1 + case 0: // V1 DeserializeInterfaceV1(reader, iface) - case 1: // version 2 + case 1: // V2: includes flex_algo_node_segments (RFC-18); requires MigrateDeviceInterfaces to have run DeserializeInterfaceV2(reader, iface) + default: + log.Println("DeserializeInterface: Unsupported interface version", iface.Version) } } @@ -122,6 +135,13 @@ func DeserializeInterfaceV2(reader *ByteReader, iface *Interface) { iface.IpNet = reader.ReadNetworkV4() iface.NodeSegmentIdx = reader.ReadU16() iface.UserTunnelEndpoint = (reader.ReadU8() != 0) + // flex_algo_node_segments (RFC-18): present in all V2 accounts after MigrateDeviceInterfaces. + length := reader.ReadU32() + iface.FlexAlgoNodeSegments = make([]FlexAlgoNodeSegment, length) + for i := uint32(0); i < length; i++ { + iface.FlexAlgoNodeSegments[i].Topology = reader.ReadPubkey() + iface.FlexAlgoNodeSegments[i].NodeSegmentIdx = reader.ReadU16() + } } func DeserializeDevice(reader *ByteReader, dev *Device) { diff --git a/smartcontract/sdk/go/serviceability/state.go b/smartcontract/sdk/go/serviceability/state.go index 065614c95e..f8dc22bb0a 100644 --- a/smartcontract/sdk/go/serviceability/state.go +++ b/smartcontract/sdk/go/serviceability/state.go @@ -385,6 +385,14 @@ func (l RoutingMode) MarshalJSON() ([]byte, error) { return json.Marshal(l.String()) } +// FlexAlgoNodeSegment is a flex-algo node segment assigned to an interface. +// Each entry pairs a TopologyInfo PDA with the segment-routing index allocated +// for this device within that topology. Written as part of Interface V2 (RFC-18). +type FlexAlgoNodeSegment struct { + Topology [32]byte // TopologyInfo PDA pubkey + NodeSegmentIdx uint16 // allocated from SegmentRoutingIds ResourceExtension +} + type Interface struct { Version uint8 Status InterfaceStatus @@ -401,6 +409,10 @@ type Interface struct { IpNet [5]uint8 NodeSegmentIdx uint16 UserTunnelEndpoint bool + // FlexAlgoNodeSegments holds flex-algo node segment assignments for this interface (RFC-18). + // Present in all V2 accounts after MigrateDeviceInterfaces has been run (empty vec for + // interfaces not yet assigned to any topology). Nil for V1 interfaces. + FlexAlgoNodeSegments []FlexAlgoNodeSegment `json:",omitempty"` } func (i Interface) MarshalJSON() ([]byte, error) { 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/lib.rs b/smartcontract/sdk/rs/src/lib.rs index 904dfac968..5f9e21d07b 100644 --- a/smartcontract/sdk/rs/src/lib.rs +++ b/smartcontract/sdk/rs/src/lib.rs @@ -8,7 +8,7 @@ pub use doublezero_serviceability::{ pda::{ get_contributor_pda, get_device_pda, get_exchange_pda, get_globalconfig_pda, get_link_pda, get_location_pda, get_multicastgroup_pda, get_permission_pda, get_resource_extension_pda, - get_tenant_pda, get_user_old_pda, + get_tenant_pda, get_topology_pda, get_user_old_pda, }, programversion::ProgramVersion, resource::{IdOrIp, ResourceType}, @@ -30,6 +30,7 @@ pub use doublezero_serviceability::{ programconfig::ProgramConfig, resource_extension::ResourceExtensionOwned, tenant::Tenant, + topology::{TopologyConstraint, TopologyInfo}, user::{BGPStatus, User, UserCYOA, UserStatus, UserType}, }, }; From 4bc9d90a9b7fba893eed96518dbacf44021a074e Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 9 Apr 2026 15:39:33 -0500 Subject: [PATCH 3/5] smartcontract,controller: fix topology mod registration and Interface comparison --- controlplane/controller/internal/controller/models.go | 2 +- smartcontract/sdk/rs/src/commands/mod.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/controlplane/controller/internal/controller/models.go b/controlplane/controller/internal/controller/models.go index 2c8a0add5b..f6107ee838 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.Name == "" { return Interface{}, errors.New("serviceability interface cannot be nil") } diff --git a/smartcontract/sdk/rs/src/commands/mod.rs b/smartcontract/sdk/rs/src/commands/mod.rs index 77bc50a37e..b5f49ff28b 100644 --- a/smartcontract/sdk/rs/src/commands/mod.rs +++ b/smartcontract/sdk/rs/src/commands/mod.rs @@ -14,5 +14,4 @@ pub mod permission; pub mod programconfig; pub mod resource; pub mod tenant; -pub mod topology; pub mod user; From 708ffdfee011d5c9694440771f9ed4f4129eff99 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 9 Apr 2026 16:04:52 -0500 Subject: [PATCH 4/5] smartcontract: restore INTERFACE_MTU default in InterfaceV2 TryFrom and Default impls --- .../programs/doublezero-serviceability/src/state/interface.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs index 1bba7fab02..445f6a01bb 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/interface.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/interface.rs @@ -368,7 +368,7 @@ impl TryFrom<&InterfaceV1> for InterfaceV2 { loopback_type: data.loopback_type, bandwidth: 0, cir: 0, - mtu: 1500, + mtu: INTERFACE_MTU, routing_mode: RoutingMode::Static, vlan_id: data.vlan_id, ip_net: data.ip_net, @@ -390,7 +390,7 @@ impl Default for InterfaceV2 { loopback_type: LoopbackType::None, bandwidth: 0, cir: 0, - mtu: 1500, + mtu: INTERFACE_MTU, routing_mode: RoutingMode::Static, vlan_id: 0, ip_net: NetworkV4::default(), From a1a2260d02443e75b1130c5ad625fe985cc30712 Mon Sep 17 00:00:00 2001 From: Ben Cairns Date: Thu, 9 Apr 2026 16:56:03 -0500 Subject: [PATCH 5/5] e2e,changelog: document mandatory CLI upgrade for RFC-18 InterfaceV2 format change --- CHANGELOG.md | 2 ++ e2e/compatibility_test.go | 72 ++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3ae1249f..8687ee149d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. ### Breaking +- CLI upgrade required: the `InterfaceV2` onchain account format now includes `flex_algo_node_segments` (RFC-18). CLI versions prior to this release cannot deserialize device accounts written by the new program. Operators must upgrade the CLI before or alongside the program upgrade. + ### Changes - Activator diff --git a/e2e/compatibility_test.go b/e2e/compatibility_test.go index 8fc0f192db..8b73326d58 100644 --- a/e2e/compatibility_test.go +++ b/e2e/compatibility_test.go @@ -121,34 +121,40 @@ 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. - "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"}}}, + // device interface create: versions 0.12.0–0.15.x sent MTU 2048; the current program + // requires 9000. Versions ≤0.11.0 and 0.16.0 can still create interfaces successfully + // (the create instruction does not require reading back the account). + "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"}}}, + + // RFC-18 mandatory upgrade boundary: all operations that read device accounts + // (set_unlinked, link create/update/delete) require a CLI that understands the new + // InterfaceV2 format (flex_algo_node_segments). All released versions prior to this + // release are incompatible. set_unlinked uses cascadeKnownFail so that downstream + // link phases are skipped rather than run-and-fail when this is a known incompatibility. + "write/device_interface_set_unlinked": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/device_interface_set_unlinked_2": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/device_interface_set_unlinked_3": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/device_interface_set_unlinked_4": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_create_wan": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_create_dzx": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_accept_dzx": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_update": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_set_health": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_set_health_dzx": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_get": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_wait_activated": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_wait_activated_dzx": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_drain": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_drain_dzx": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_delete": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/link_delete_dzx": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/device_interface_delete": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/device_interface_delete_2": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/device_interface_delete_3": {ranges: []versionRange{{before: "0.17.0"}}}, + "write/device_interface_delete_4": {ranges: []versionRange{{before: "0.17.0"}}}, } // ============================================================================= @@ -1170,14 +1176,16 @@ func runWriteWorkflows( }}, // Transition all 4 interfaces to "unlinked" (required before link creation). + // cascadeKnownFail: when these are known-incompatible, downstream phases are + // skipped rather than allowed to run and fail independently. {name: "activate_interfaces", parallel: true, steps: []writeStep{ - {name: "device_interface_set_unlinked", cmd: cli + " device interface update " + deviceCode + " " + ifaceName + + {name: "device_interface_set_unlinked", cascadeKnownFail: true, cmd: cli + " device interface update " + deviceCode + " " + ifaceName + " --status unlinked"}, - {name: "device_interface_set_unlinked_2", cmd: cli + " device interface update " + deviceCode2 + " " + ifaceName + + {name: "device_interface_set_unlinked_2", cascadeKnownFail: true, cmd: cli + " device interface update " + deviceCode2 + " " + ifaceName + " --status unlinked"}, - {name: "device_interface_set_unlinked_3", cmd: cli + " device interface update " + deviceCode + " " + ifaceName2 + + {name: "device_interface_set_unlinked_3", cascadeKnownFail: true, cmd: cli + " device interface update " + deviceCode + " " + ifaceName2 + " --status unlinked"}, - {name: "device_interface_set_unlinked_4", cmd: cli + " device interface update " + deviceCode2 + " " + ifaceName2 + + {name: "device_interface_set_unlinked_4", cascadeKnownFail: true, cmd: cli + " device interface update " + deviceCode2 + " " + ifaceName2 + " --status unlinked"}, }},