From 78b6b13743c99b9f317de0369086c2092ddb55cc Mon Sep 17 00:00:00 2001 From: Martin Sander Date: Tue, 3 Feb 2026 19:06:38 -0600 Subject: [PATCH] activator: assign multicast publisher IPs from global pool in GlobalConfig Previously, multicast publishers were assigned dz_ips from per-device IP blocks. This meant publishers couldn't easily migrate between devices or have consistent IPs across the network. This change introduces a global multicast_publisher_block in the serviceability GlobalConfig that serves as a dedicated IP pool for all multicast publishers. Off-chain (activator) changes: - New IPBlockAllocator for publisher IPs initialized from config block - Publishers allocated from global pool, IBRL users still use device blocks - Existing publisher allocations loaded on startup to prevent conflicts - Allocations synced to on-chain bitmap for cross-activator consistency - Proper deallocation when publishers disconnect or are deleted - Falls back to on-chain allocation if no local pool configured On-chain (program/CLI) changes: - multicast_publisher_block field added to GlobalConfig state - MulticastPublisherBlock resource type for bitmap allocation/deallocation - CLI --multicast-publisher-block flag for global-config set command - Activate/CloseAccount processors updated to handle new resource type Includes E2E tests verifying: - Multiple publishers get unique sequential IPs from global block - Publishers and IBRL users coexist with separate IP pools - IP deallocation and reuse works correctly --- CHANGELOG.md | 2 + activator/src/activator.rs | 1 + activator/src/process/user.rs | 173 ++++++- activator/src/processor.rs | 26 +- activator/src/tests.rs | 1 + client/doublezero/src/command/connect.rs | 1 + e2e/internal/devnet/smartcontract_init.go | 4 +- e2e/internal/netutil/ip.go | 13 + e2e/multicast_publisher_ip_test.go | 438 ++++++++++++++++++ e2e/multicast_test.go | 35 +- .../cli/src/globalconfig/authority/get.rs | 1 + smartcontract/cli/src/globalconfig/get.rs | 1 + smartcontract/cli/src/globalconfig/set.rs | 10 + smartcontract/cli/src/resource/allocate.rs | 1 + smartcontract/cli/src/resource/deallocate.rs | 1 + smartcontract/cli/src/resource/mod.rs | 8 + .../src/instructions.rs | 6 +- .../doublezero-serviceability/src/pda.rs | 13 +- .../src/processors/globalconfig/set.rs | 26 +- .../src/processors/resource/mod.rs | 3 + .../src/processors/user/activate.rs | 67 ++- .../src/processors/user/closeaccount.rs | 90 +++- .../doublezero-serviceability/src/resource.rs | 1 + .../doublezero-serviceability/src/seeds.rs | 1 + .../src/state/globalconfig.rs | 34 +- .../tests/accesspass_allow_multiple_ip.rs | 4 +- .../tests/device_test.rs | 2 + .../tests/device_update_location_test.rs | 1 + .../tests/exchange_setdevice.rs | 1 + .../tests/exchange_test.rs | 5 + .../tests/global_test.rs | 1 + .../tests/interface_test.rs | 1 + .../tests/link_dzx_test.rs | 1 + .../tests/link_wan_test.rs | 1 + .../tests/multicastgroup_subscribe_test.rs | 1 + .../tests/test_helpers.rs | 1 + .../tests/user_migration.rs | 1 + .../tests/user_old_test.rs | 4 +- .../tests/user_onchain_allocation_test.rs | 4 +- .../tests/user_tests.rs | 5 +- .../tests/test_helpers.rs | 1 + .../sdk/rs/src/commands/device/activate.rs | 1 + .../sdk/rs/src/commands/device/sethealth.rs | 1 + .../sdk/rs/src/commands/device/update.rs | 1 + .../sdk/rs/src/commands/globalconfig/set.rs | 12 + .../sdk/rs/src/commands/user/activate.rs | 12 + .../sdk/rs/src/commands/user/closeaccount.rs | 75 ++- 47 files changed, 996 insertions(+), 97 deletions(-) create mode 100644 e2e/multicast_publisher_ip_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e8b108698..66fd6d42e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## Unreleased +- Activator + - Assign multicast publisher IPs from global pool in serviceability GlobalConfig instead of per-device blocks - CLI - Remove log noise on resolve route - Onchain programs diff --git a/activator/src/activator.rs b/activator/src/activator.rs index 8648f9c48..0c4c23179 100644 --- a/activator/src/activator.rs +++ b/activator/src/activator.rs @@ -400,6 +400,7 @@ mod tests { device_tunnel_block: "1.0.0.0/24".parse().unwrap(), user_tunnel_block: "1.0.1.0/24".parse().unwrap(), multicastgroup_block: "239.239.239.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 65535, }; mock_client diff --git a/activator/src/process/user.rs b/activator/src/process/user.rs index 65727df6d..49f1899fe 100644 --- a/activator/src/process/user.rs +++ b/activator/src/process/user.rs @@ -6,12 +6,13 @@ use doublezero_program_common::types::NetworkV4; use doublezero_sdk::{ commands::{ device::get::GetDeviceCommand, + resource::{allocate::AllocateResourceCommand, deallocate::DeallocateResourceCommand}, user::{ activate::ActivateUserCommand, ban::BanUserCommand, closeaccount::CloseAccountUserCommand, reject::RejectUserCommand, }, }, - DoubleZeroClient, Exchange, Location, User, UserStatus, UserType, + DoubleZeroClient, Exchange, IdOrIp, Location, ResourceType, User, UserStatus, UserType, }; use doublezero_serviceability::error::DoubleZeroError; use log::{info, warn}; @@ -28,6 +29,7 @@ pub fn process_user_event( pubkey: &Pubkey, devices: &mut DeviceMap, user_tunnel_ips: &mut IPBlockAllocator, + publisher_dz_ips: &mut Option, link_ids: &mut IDAllocator, user: &User, locations: &HashMap, @@ -105,33 +107,108 @@ pub fn process_user_event( UserType::Multicast => !user.publishers.is_empty(), }; - let dz_ip = if need_dz_ip { - match device_state.get_next_dz_ip() { - Some(ip) => ip, - None => { - let res = - reject_user(client, pubkey, "Error: No available dz_ip to allocate"); + // Determine allocation strategy for dz_ip: + // - Multicast publishers: use publisher_dz_ips pool if available, otherwise onchain + // - Other types: respect use_onchain_allocation flag + let is_publisher = user.user_type == UserType::Multicast && !user.publishers.is_empty(); + let use_onchain_dz_ip = if is_publisher { + use_onchain_allocation || publisher_dz_ips.is_none() + } else { + use_onchain_allocation + }; - match res { - Ok(signature) => { - write!( - &mut log_msg, - " Reject(No available dz_ip to allocate) Rejected {signature}" - ) - .unwrap(); + let dz_ip = if need_dz_ip && !use_onchain_dz_ip { + // Offchain allocation + if is_publisher { + // Publishers: allocate from global publisher pool + if let Some(ref mut publisher_ips) = publisher_dz_ips { + match publisher_ips.next_available_block(1, 1).map(|net| net.ip()) { + Some(ip) => { + // Sync off-chain allocation to on-chain bitmap + // This ensures on-chain allocator knows about off-chain allocations + if let Ok(ip_net) = NetworkV4::new(ip, 32) { + let sync_result = AllocateResourceCommand { + resource_type: ResourceType::MulticastPublisherBlock, + requested: Some(IdOrIp::Ip(ip_net)), + } + .execute(client); + + if let Err(e) = sync_result { + warn!( + "Failed to sync off-chain publisher IP {} to on-chain: {}", + ip, e + ); + // Continue anyway - off-chain allocation is still valid + } + } + ip } - Err(e) => { - write!( - &mut log_msg, - " Reject(No available dz_ip to allocate) Error: {e}" - ) - .unwrap(); + None => { + let res = reject_user( + client, + pubkey, + "Error: No available publisher dz_ip to allocate", + ); + + match res { + Ok(signature) => { + write!( + &mut log_msg, + " Reject(No available publisher dz_ip) Rejected {signature}" + ) + .unwrap(); + } + Err(e) => { + write!( + &mut log_msg, + " Reject(No available publisher dz_ip) Error: {e}" + ) + .unwrap(); + } + } + info!("{log_msg}"); + return; } } - info!("{log_msg}"); - return; + } else { + // Should never happen due to use_onchain_dz_ip check above + Ipv4Addr::UNSPECIFIED + } + } else { + // IBRL/EdgeFiltering: allocate from device state + match device_state.get_next_dz_ip() { + Some(ip) => ip, + None => { + let res = reject_user( + client, + pubkey, + "Error: No available dz_ip to allocate", + ); + + match res { + Ok(signature) => { + write!( + &mut log_msg, + " Reject(No available dz_ip to allocate) Rejected {signature}" + ) + .unwrap(); + } + Err(e) => { + write!( + &mut log_msg, + " Reject(No available dz_ip to allocate) Error: {e}" + ) + .unwrap(); + } + } + info!("{log_msg}"); + return; + } } } + } else if need_dz_ip { + // Onchain allocation: pass UNSPECIFIED so smart contract allocates + Ipv4Addr::UNSPECIFIED } else { user.client_ip }; @@ -139,16 +216,24 @@ pub fn process_user_event( write!(&mut log_msg, " tunnel_id: {} dz_ip: {} ", tunnel_id, &dz_ip).unwrap(); // Activate the user + // Force onchain allocation for multicast publishers if no local publisher pool + let use_onchain_for_activation = + use_onchain_allocation || (is_publisher && publisher_dz_ips.is_none()); + let res = ActivateUserCommand { user_pubkey: *pubkey, - tunnel_id: if use_onchain_allocation { 0 } else { tunnel_id }, - tunnel_net: if use_onchain_allocation { + tunnel_id: if use_onchain_for_activation { + 0 + } else { + tunnel_id + }, + tunnel_net: if use_onchain_for_activation { NetworkV4::default() } else { tunnel_net.into() }, dz_ip, - use_onchain_allocation, + use_onchain_allocation: use_onchain_for_activation, } .execute(client); @@ -312,6 +397,37 @@ pub fn process_user_event( if user.tunnel_net != NetworkV4::default() { user_tunnel_ips.unassign_block(user.tunnel_net.into()); } + // Deallocate publisher dz_ip from global pool + if user.user_type == UserType::Multicast + && !user.publishers.is_empty() + && user.dz_ip != Ipv4Addr::UNSPECIFIED + && user.dz_ip != user.client_ip + { + if let Some(ref mut publisher_ips) = publisher_dz_ips { + if let Ok(dz_ip_net) = NetworkV4::new(user.dz_ip, 32) { + publisher_ips.unassign_block(dz_ip_net.into()); + info!( + "Deallocated publisher dz_ip {} from global pool", + user.dz_ip + ); + + // Sync deallocation to on-chain bitmap + let sync_result = DeallocateResourceCommand { + resource_type: + ResourceType::MulticastPublisherBlock, + value: IdOrIp::Ip(dz_ip_net), + } + .execute(client); + + if let Err(e) = sync_result { + warn!( + "Failed to sync off-chain publisher IP {} deallocation to on-chain: {}", + user.dz_ip, e + ); + } + } + } + } } if user.dz_ip != Ipv4Addr::UNSPECIFIED { device_state.release(user.dz_ip, user.tunnel_id).unwrap(); @@ -575,6 +691,7 @@ mod tests { &user_pubkey, &mut devices, &mut user_tunnel_ips, + &mut None, // publisher_dz_ips &mut link_ids, &user, &locations, @@ -771,6 +888,7 @@ mod tests { &user_pubkey, &mut devices, &mut user_tunnel_ips, + &mut None, // publisher_dz_ips &mut link_ids, &user, &locations, @@ -875,6 +993,7 @@ mod tests { &user_pubkey, &mut devices, &mut user_tunnel_ips, + &mut None, // publisher_dz_ips &mut link_ids, &user, &locations, @@ -983,6 +1102,7 @@ mod tests { &user_pubkey, &mut devices, &mut user_tunnel_ips, + &mut None, // publisher_dz_ips &mut link_ids, &user, &locations, @@ -1088,6 +1208,7 @@ mod tests { &user_pubkey, &mut devices, &mut user_tunnel_ips, + &mut None, // publisher_dz_ips &mut link_ids, &user, &locations, @@ -1194,6 +1315,7 @@ mod tests { &user_pubkey, &mut devices, &mut user_tunnel_ips, + &mut None, // publisher_dz_ips &mut link_ids, &user, &locations, @@ -1248,6 +1370,7 @@ mod tests { predicate::eq(DoubleZeroInstruction::CloseAccountUser( UserCloseAccountArgs { dz_prefix_count: 0, // legacy path + multicast_publisher_count: 0, }, )), predicate::always(), diff --git a/activator/src/processor.rs b/activator/src/processor.rs index 77bfd0fae..8434c13e3 100644 --- a/activator/src/processor.rs +++ b/activator/src/processor.rs @@ -10,6 +10,7 @@ use crate::{ states::devicestate::DeviceState, }; use backon::{BlockingRetryable, ExponentialBuilder}; +use doublezero_program_common::types::NetworkV4; use doublezero_sdk::{ commands::{ device::list::ListDeviceCommand, exchange::list::ListExchangeCommand, @@ -18,7 +19,7 @@ use doublezero_sdk::{ }, doublezeroclient::DoubleZeroClient, AccountData, DeviceStatus, Exchange, GetGlobalConfigCommand, InterfaceType, LinkStatus, - Location, MulticastGroup, UserStatus, + Location, MulticastGroup, UserStatus, UserType, }; use log::{debug, error, info, warn}; use solana_sdk::pubkey::Pubkey; @@ -42,6 +43,7 @@ pub struct Processor { link_ips: IPBlockAllocator, multicastgroup_tunnel_ips: IPBlockAllocator, user_tunnel_ips: IPBlockAllocator, + publisher_dz_ips: Option, devices: DeviceMap, locations: LocationMap, exchanges: ExchangeMap, @@ -79,6 +81,9 @@ impl Processor { let mut link_ips = IPBlockAllocator::new(config.device_tunnel_block.into()); let mut segment_routing_ids = IDAllocator::new(1, vec![]); let mut user_tunnel_ips = IPBlockAllocator::new(config.user_tunnel_block.into()); + let mut publisher_dz_ips = Some(IPBlockAllocator::new( + config.multicast_publisher_block.into(), + )); for (_, link) in links .iter() @@ -122,6 +127,23 @@ impl Processor { ) })?; user_tunnel_ips.assign_block(user.tunnel_net.into()); + + // Mark publisher IPs as allocated in the publisher pool + if user.user_type == UserType::Multicast + && !user.publishers.is_empty() + && user.dz_ip != std::net::Ipv4Addr::UNSPECIFIED + && user.dz_ip != user.client_ip + { + if let Some(ref mut publisher_ips) = publisher_dz_ips { + if let Ok(dz_ip_net) = NetworkV4::new(user.dz_ip, 32) { + publisher_ips.assign_block(dz_ip_net.into()); + info!( + "Marked publisher dz_ip {} as allocated (loaded from existing user)", + user.dz_ip + ); + } + } + } } Ok::<(), eyre::Error>(()) })?; @@ -141,6 +163,7 @@ impl Processor { segment_routing_ids, multicastgroup_tunnel_ips: IPBlockAllocator::new(config.multicastgroup_block.into()), user_tunnel_ips, + publisher_dz_ips, devices: device_map, locations, exchanges, @@ -190,6 +213,7 @@ impl Processor { pubkey, &mut self.devices, &mut self.user_tunnel_ips, + &mut self.publisher_dz_ips, &mut self.link_ids, user, &self.locations, diff --git a/activator/src/tests.rs b/activator/src/tests.rs index b4195bf62..b946b0dfd 100644 --- a/activator/src/tests.rs +++ b/activator/src/tests.rs @@ -47,6 +47,7 @@ pub mod utils { device_tunnel_block: "1.0.0.0/24".parse().unwrap(), user_tunnel_block: "2.0.0.0/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 0, }; diff --git a/client/doublezero/src/command/connect.rs b/client/doublezero/src/command/connect.rs index e35d8aabf..ab0b8eb5c 100644 --- a/client/doublezero/src/command/connect.rs +++ b/client/doublezero/src/command/connect.rs @@ -822,6 +822,7 @@ mod tests { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), user_tunnel_block: "10.0.1.0/24".parse().unwrap(), multicastgroup_block: "239.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 10000, }, client: create_test_client(), diff --git a/e2e/internal/devnet/smartcontract_init.go b/e2e/internal/devnet/smartcontract_init.go index 5c418526e..4eb682869 100644 --- a/e2e/internal/devnet/smartcontract_init.go +++ b/e2e/internal/devnet/smartcontract_init.go @@ -91,8 +91,8 @@ func (dn *Devnet) InitSmartContract(ctx context.Context) error { # Populate global configuration onchain. echo "==> Populating global configuration onchain" - echo doublezero global-config set --local-asn 65000 --remote-asn 65342 --device-tunnel-block ` + dn.Spec.DeviceTunnelNet + ` --user-tunnel-block 169.254.0.0/16 --multicastgroup-block 233.84.178.0/24 - doublezero global-config set --local-asn 65000 --remote-asn 65342 --device-tunnel-block ` + dn.Spec.DeviceTunnelNet + ` --user-tunnel-block 169.254.0.0/16 --multicastgroup-block 233.84.178.0/24 + echo doublezero global-config set --local-asn 65000 --remote-asn 65342 --device-tunnel-block ` + dn.Spec.DeviceTunnelNet + ` --user-tunnel-block 169.254.0.0/16 --multicastgroup-block 233.84.178.0/24 --multicast-publisher-block 147.51.126.0/23 + doublezero global-config set --local-asn 65000 --remote-asn 65342 --device-tunnel-block ` + dn.Spec.DeviceTunnelNet + ` --user-tunnel-block 169.254.0.0/16 --multicastgroup-block 233.84.178.0/24 --multicast-publisher-block 147.51.126.0/23 echo "--> Global configuration onchain:" doublezero global-config get echo diff --git a/e2e/internal/netutil/ip.go b/e2e/internal/netutil/ip.go index ee9a1ed68..e79dfbef5 100644 --- a/e2e/internal/netutil/ip.go +++ b/e2e/internal/netutil/ip.go @@ -40,3 +40,16 @@ func ParseCIDR(cidr string) (string, *net.IPNet, error) { } return ip.String(), network, nil } + +// IPInRange checks if an IP is within a CIDR block. +func IPInRange(ipStr, cidr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + _, network, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + return network.Contains(ip) +} diff --git a/e2e/multicast_publisher_ip_test.go b/e2e/multicast_publisher_ip_test.go new file mode 100644 index 000000000..fe2c66c72 --- /dev/null +++ b/e2e/multicast_publisher_ip_test.go @@ -0,0 +1,438 @@ +//go:build e2e + +package e2e_test + +import ( + "strings" + "testing" + "time" + + "github.com/malbeclabs/doublezero/e2e/internal/devnet" + "github.com/malbeclabs/doublezero/e2e/internal/fixtures" + "github.com/malbeclabs/doublezero/e2e/internal/netutil" + "github.com/stretchr/testify/require" +) + +// publisherUserInfo holds info for a multicast publisher user parsed from user list output. +type publisherUserInfo struct { + ClientIP string + DzIP string + UserType string +} + +// parsePublisherUserInfo parses user list output and returns info for Multicast users. +func parsePublisherUserInfo(output []byte) []publisherUserInfo { + rows := fixtures.ParseCLITable(output) + var users []publisherUserInfo + for _, row := range rows { + if row["user_type"] == "Multicast" { + users = append(users, publisherUserInfo{ + ClientIP: row["client_ip"], + DzIP: row["dz_ip"], + UserType: row["user_type"], + }) + } + } + return users +} + +// TestE2E_MulticastPublisher_MultipleAllocations verifies that multiple publishers +// get unique sequential IPs from the global multicast_publisher_block (147.51.126.0/23). +func TestE2E_MulticastPublisher_MultipleAllocations(t *testing.T) { + t.Parallel() + + dn, _, client1 := NewSingleDeviceSingleClientTestDevnet(t) + + // Add two more clients for additional publishers + client2, err := dn.Devnet.AddClient(t.Context(), devnet.ClientSpec{ + CYOANetworkIPHostID: 101, + }) + require.NoError(t, err) + + client3, err := dn.Devnet.AddClient(t.Context(), devnet.ClientSpec{ + CYOANetworkIPHostID: 102, + }) + require.NoError(t, err) + + // Set access passes for all clients + for _, client := range []*devnet.Client{client1, client2, client3} { + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey, + }) + require.NoError(t, err) + } + + // Create three multicast groups + for _, groupCode := range []string{"mg01", "mg02", "mg03"} { + dn.CreateMulticastGroupOnchain(t, client1, groupCode) + + // Add all clients to publisher allowlist + for _, client := range []*devnet.Client{client1, client2, client3} { + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero multicast group allowlist publisher add --code " + groupCode + + " --user-payer " + client.Pubkey + " --client-ip " + client.CYOANetworkIP, + }) + require.NoError(t, err) + } + } + + if !t.Run("connect_and_verify", func(t *testing.T) { + // Connect each client as publisher to different multicast groups + dn.ConnectMulticastPublisherSkipAccessPass(t, client1, "mg01") + dn.ConnectMulticastPublisherSkipAccessPass(t, client2, "mg02") + dn.ConnectMulticastPublisherSkipAccessPass(t, client3, "mg03") + + // Wait for all tunnels to come up + for _, client := range []*devnet.Client{client1, client2, client3} { + err := client.WaitForTunnelUp(t.Context(), 90*time.Second) + require.NoError(t, err) + } + + // Query user list from manager to get dz_ip allocations + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) + require.NoError(t, err) + + publishers := parsePublisherUserInfo(userListOutput) + require.Len(t, publishers, 3, "should have 3 multicast publisher users") + + // Verify all IPs are from global multicast_publisher_block (147.51.126.0/23) + // and are sequential (147.51.126.1, .2, .3) + dzIPs := []string{} + for _, pub := range publishers { + require.True(t, netutil.IPInRange(pub.DzIP, "147.51.126.0/23"), + "publisher dz_ip %s should be in global multicast_publisher_block 147.51.126.0/23", pub.DzIP) + dzIPs = append(dzIPs, pub.DzIP) + } + + // Verify sequential allocation - this also guarantees uniqueness + expectedIPs := []string{"147.51.126.0", "147.51.126.1", "147.51.126.2"} + for _, expectedIP := range expectedIPs { + require.Contains(t, dzIPs, expectedIP, + "expected IP %s should be allocated", expectedIP) + } + + dn.log.Info("✓ All publishers allocated unique sequential IPs from global block", + "dz_ips", dzIPs) + }) { + t.Fail() + return + } + + if !t.Run("disconnect", func(t *testing.T) { + dn.DisconnectMulticastPublisher(t, client1) + dn.DisconnectMulticastPublisher(t, client2) + dn.DisconnectMulticastPublisher(t, client3) + }) { + t.Fail() + } +} + +// TestE2E_MulticastPublisher_MixedUsers verifies that publishers and IBRL users +// coexist with separate IP pools (publishers use global block, IBRL uses device block). +func TestE2E_MulticastPublisher_MixedUsers(t *testing.T) { + t.Parallel() + + dn, device, publisherClient := NewSingleDeviceSingleClientTestDevnet(t) + + // Add an IBRL client + ibrlClient, err := dn.Devnet.AddClient(t.Context(), devnet.ClientSpec{ + CYOANetworkIPHostID: 101, + }) + require.NoError(t, err) + + // Set access passes + for _, client := range []*devnet.Client{publisherClient, ibrlClient} { + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey, + }) + require.NoError(t, err) + } + + // Create multicast group for publisher + dn.CreateMulticastGroupOnchain(t, publisherClient, "mg01") + _, err = dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero multicast group allowlist publisher add --code mg01" + + " --user-payer " + publisherClient.Pubkey + " --client-ip " + publisherClient.CYOANetworkIP, + }) + require.NoError(t, err) + + if !t.Run("connect_and_verify", func(t *testing.T) { + // Connect publisher + dn.ConnectMulticastPublisherSkipAccessPass(t, publisherClient, "mg01") + err := publisherClient.WaitForTunnelUp(t.Context(), 90*time.Second) + require.NoError(t, err) + + // Connect IBRL user with allocated IP + dn.ConnectUserTunnelWithAllocatedIP(t, ibrlClient) + err = ibrlClient.WaitForTunnelUp(t.Context(), 90*time.Second) + require.NoError(t, err) + + // Query user list + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) + require.NoError(t, err) + + rows := fixtures.ParseCLITable(userListOutput) + require.Len(t, rows, 2, "should have 2 users (1 publisher + 1 IBRL)") + + var publisherDzIP, ibrlDzIP string + for _, row := range rows { + switch row["user_type"] { + case "Multicast": + publisherDzIP = row["dz_ip"] + case "IBRLWithAllocatedIP": + ibrlDzIP = row["dz_ip"] + } + } + + require.NotEmpty(t, publisherDzIP, "publisher should have dz_ip") + require.NotEmpty(t, ibrlDzIP, "IBRL user should have dz_ip") + + // Verify publisher uses global multicast_publisher_block + require.True(t, netutil.IPInRange(publisherDzIP, "147.51.126.0/23"), + "publisher dz_ip %s should be from global block 147.51.126.0/23", publisherDzIP) + + // Verify IBRL user uses device DzPrefixBlock + require.True(t, netutil.IPInRange(ibrlDzIP, device.DZPrefix), + "IBRL dz_ip %s should be from device block %s", ibrlDzIP, device.DZPrefix) + + // Verify IPs don't conflict + require.NotEqual(t, publisherDzIP, ibrlDzIP, + "publisher and IBRL user should have different IPs") + + dn.log.Info("✓ Publisher and IBRL user coexist with separate IP pools", + "publisher_dz_ip", publisherDzIP, "publisher_pool", "147.51.126.0/23", + "ibrl_dz_ip", ibrlDzIP, "ibrl_pool", device.DZPrefix) + }) { + t.Fail() + return + } + + if !t.Run("disconnect", func(t *testing.T) { + dn.DisconnectMulticastPublisher(t, publisherClient) + dn.DisconnectUserTunnel(t, ibrlClient) + }) { + t.Fail() + } +} + +// TestE2E_MulticastPublisher_IPDeallocation verifies that IPs are returned to the pool +// when publishers are deleted and can be reused by new publishers. +func TestE2E_MulticastPublisher_IPDeallocation(t *testing.T) { + t.Parallel() + + dn, _, client1 := NewSingleDeviceSingleClientTestDevnet(t) + + // Add second client + client2, err := dn.Devnet.AddClient(t.Context(), devnet.ClientSpec{ + CYOANetworkIPHostID: 101, + }) + require.NoError(t, err) + + // Set access passes + for _, client := range []*devnet.Client{client1, client2} { + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey, + }) + require.NoError(t, err) + } + + // Create multicast groups + for _, groupCode := range []string{"mg01", "mg02"} { + dn.CreateMulticastGroupOnchain(t, client1, groupCode) + for _, client := range []*devnet.Client{client1, client2} { + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero multicast group allowlist publisher add --code " + groupCode + + " --user-payer " + client.Pubkey + " --client-ip " + client.CYOANetworkIP, + }) + require.NoError(t, err) + } + } + + var firstPublisherIP string + + if !t.Run("connect_first_publisher", func(t *testing.T) { + // Connect first publisher + dn.ConnectMulticastPublisherSkipAccessPass(t, client1, "mg01") + err := client1.WaitForTunnelUp(t.Context(), 90*time.Second) + require.NoError(t, err) + + // Query and record the IP + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) + require.NoError(t, err) + + publishers := parsePublisherUserInfo(userListOutput) + require.Len(t, publishers, 1, "should have 1 publisher") + firstPublisherIP = publishers[0].DzIP + + require.True(t, netutil.IPInRange(firstPublisherIP, "147.51.126.0/23"), + "first publisher dz_ip %s should be from global block", firstPublisherIP) + + dn.log.Info("✓ First publisher connected", "dz_ip", firstPublisherIP) + }) { + t.Fail() + return + } + + if !t.Run("disconnect_and_delete_first_publisher", func(t *testing.T) { + // Disconnect first publisher + dn.DisconnectMulticastPublisher(t, client1) + + // Poll for user removal with timeout + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) + require.NoError(t, err) + + publishers := parsePublisherUserInfo(userListOutput) + if len(publishers) == 0 { + // User successfully removed + dn.log.Info("✓ First publisher disconnected and deleted", "freed_ip", firstPublisherIP) + return + } + time.Sleep(1 * time.Second) + } + t.Fatalf("publisher user has not been removed after disconnect") + + }) { + t.Fail() + return + } + + if !t.Run("connect_second_publisher_reuses_ip", func(t *testing.T) { + // Connect second publisher - should reuse the first IP + dn.ConnectMulticastPublisherSkipAccessPass(t, client2, "mg02") + err := client2.WaitForTunnelUp(t.Context(), 90*time.Second) + require.NoError(t, err) + + // Query and verify IP is reused + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) + require.NoError(t, err) + + publishers := parsePublisherUserInfo(userListOutput) + require.Len(t, publishers, 1, "should have 1 publisher") + secondPublisherIP := publishers[0].DzIP + + // The IP should be reused (bitmap allocates from lowest available) + require.Equal(t, firstPublisherIP, secondPublisherIP, + "second publisher should reuse the first publisher's IP after deallocation") + + dn.log.Info("✓ Second publisher reused deallocated IP", + "reused_ip", secondPublisherIP, "original_ip", firstPublisherIP) + }) { + t.Fail() + return + } + + if !t.Run("disconnect", func(t *testing.T) { + dn.DisconnectMulticastPublisher(t, client2) + }) { + t.Fail() + } +} + +// TestE2E_MulticastPublisher_BothAllocationPaths verifies that off-chain publisher IP +// allocations are synced to the on-chain ResourceExtension bitmap, and deallocations +// are also synced back. +func TestE2E_MulticastPublisher_BothAllocationPaths(t *testing.T) { + t.Parallel() + + dn, _, client1 := NewSingleDeviceSingleClientTestDevnet(t) + + // Add one more client + client2, err := dn.Devnet.AddClient(t.Context(), devnet.ClientSpec{ + CYOANetworkIPHostID: 101, + }) + require.NoError(t, err) + + // Set access passes for all clients + for _, client := range []*devnet.Client{client1, client2} { + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero access-pass set --accesspass-type prepaid --client-ip " + client.CYOANetworkIP + " --user-payer " + client.Pubkey, + }) + require.NoError(t, err) + } + + // Create three multicast groups + for _, groupCode := range []string{"mg01", "mg02"} { + dn.CreateMulticastGroupOnchain(t, client1, groupCode) + + // Add all clients to publisher allowlist + for _, client := range []*devnet.Client{client1, client2} { + _, err := dn.Manager.Exec(t.Context(), []string{"bash", "-c", + "doublezero multicast group allowlist publisher add --code " + groupCode + + " --user-payer " + client.Pubkey + " --client-ip " + client.CYOANetworkIP, + }) + require.NoError(t, err) + } + } + + if !t.Run("connect_and_verify", func(t *testing.T) { + // Connect both clients as publishers to different multicast groups + dn.ConnectMulticastPublisherSkipAccessPass(t, client1, "mg01") + dn.ConnectMulticastPublisherSkipAccessPass(t, client2, "mg02") + + // Wait for all tunnels to come up + for _, client := range []*devnet.Client{client1, client2} { + err := client.WaitForTunnelUp(t.Context(), 90*time.Second) + require.NoError(t, err) + } + + // Query user list to get allocated IPs + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) + require.NoError(t, err) + + publishers := parsePublisherUserInfo(userListOutput) + require.Len(t, publishers, 2, "should have 2 multicast publisher users") + + // Collect allocated IPs + dzIPs := []string{} + for _, pub := range publishers { + require.True(t, netutil.IPInRange(pub.DzIP, "147.51.126.0/23"), + "publisher dz_ip %s should be in global multicast_publisher_block", pub.DzIP) + dzIPs = append(dzIPs, pub.DzIP) + } + + // Verify off-chain allocations are synced to on-chain resource bitmap + resourceOutput, err := dn.Manager.Exec(t.Context(), []string{ + "doublezero", "resource", "get", + "--resource-type", "multicast-publisher-block", + }) + require.NoError(t, err) + resourceStr := string(resourceOutput) + + // Both IPs should appear in the on-chain bitmap + for _, ip := range dzIPs { + require.Contains(t, resourceStr, ip+"/32", + "off-chain allocated IP %s should be synced to on-chain bitmap", ip) + } + + dn.log.Info("✓ Off-chain allocations synced to on-chain bitmap", + "dz_ips", dzIPs) + }) { + t.Fail() + return + } + + if !t.Run("disconnect_and_verify_deallocation", func(t *testing.T) { + // Disconnect both publishers + dn.DisconnectMulticastPublisher(t, client1) + dn.DisconnectMulticastPublisher(t, client2) + + // Wait for deallocation to sync to on-chain + require.Eventually(t, func() bool { + resourceOutput, err := dn.Manager.Exec(t.Context(), []string{ + "doublezero", "resource", "get", + "--resource-type", "multicast-publisher-block", + }) + if err != nil { + return false + } + // Bitmap should be empty after deallocation + return !strings.Contains(string(resourceOutput), "147.51.126") + }, 30*time.Second, 2*time.Second, "IPs should be deallocated from on-chain bitmap") + + dn.log.Info("✓ Off-chain deallocations synced to on-chain bitmap") + }) { + t.Fail() + } +} diff --git a/e2e/multicast_test.go b/e2e/multicast_test.go index 6e0f18ed5..3648cacad 100644 --- a/e2e/multicast_test.go +++ b/e2e/multicast_test.go @@ -278,15 +278,21 @@ func createMulticastGroupForBothClients(t *testing.T, dn *TestDevnet, publisherC // and subscriber tunnels configured. func checkMulticastBothUsersAgentConfig(t *testing.T, dn *TestDevnet, device *devnet.Device, publisherClient, subscriberClient *devnet.Client) { t.Run("wait_for_agent_config_both_users", func(t *testing.T) { - dzPrefixIP, dzPrefixNet, err := netutil.ParseCIDR(device.DZPrefix) - require.NoError(t, err) - ones, _ := dzPrefixNet.Mask.Size() - allocatableBits := 32 - ones - // With onchain allocation, the first IP is reserved for the device tunnel endpoint. - expectedAllocatedPublisherIP, err := nextAllocatableIP(dzPrefixIP, allocatableBits, map[string]bool{dzPrefixIP: true}) + // Query the allocated publisher IP from user list + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) require.NoError(t, err) + rows := fixtures.ParseCLITable(userListOutput) + var expectedAllocatedPublisherIP string + for _, row := range rows { + if row["user_type"] == "Multicast" && row["client_ip"] == publisherClient.CYOANetworkIP { + expectedAllocatedPublisherIP = row["dz_ip"] + break + } + } + require.NotEmpty(t, expectedAllocatedPublisherIP, "publisher should have allocated dz_ip") + // Publisher gets the first tunnel slot, subscriber gets the second. pubTunnel := controllerconfig.StartUserTunnelNum subTunnel := controllerconfig.StartUserTunnelNum + 1 @@ -338,14 +344,19 @@ func checkMulticastPostConnect(t *testing.T, log *slog.Logger, mode string, dn * var expectedAllocatedClientIP string if mode == "publisher" { - dzPrefixIP, dzPrefixNet, err := netutil.ParseCIDR(device.DZPrefix) + // Query the allocated publisher IP from user list + userListOutput, err := dn.Manager.Exec(t.Context(), []string{"doublezero", "user", "list"}) require.NoError(t, err) - ones, _ := dzPrefixNet.Mask.Size() - allocatableBits := 32 - ones - // With onchain allocation, the first IP is reserved for the device tunnel endpoint. - expectedAllocatedClientIP, err = nextAllocatableIP(dzPrefixIP, allocatableBits, map[string]bool{dzPrefixIP: true}) - require.NoError(t, err) + rows := fixtures.ParseCLITable(userListOutput) + for _, row := range rows { + if row["user_type"] == "Multicast" && row["client_ip"] == client.CYOANetworkIP { + expectedAllocatedClientIP = row["dz_ip"] + break + } + } + require.NotEmpty(t, expectedAllocatedClientIP, "publisher should have allocated dz_ip") + require.True(t, netutil.IPInRange(expectedAllocatedClientIP, "147.51.126.0/23"), "publisher dz_ip %s should be from global multicast_publisher_block", expectedAllocatedClientIP) } tests := []struct { diff --git a/smartcontract/cli/src/globalconfig/authority/get.rs b/smartcontract/cli/src/globalconfig/authority/get.rs index 4bec8e07a..0c6e9d4a2 100644 --- a/smartcontract/cli/src/globalconfig/authority/get.rs +++ b/smartcontract/cli/src/globalconfig/authority/get.rs @@ -61,6 +61,7 @@ mod tests { device_tunnel_block: "10.1.0.0/24".parse().unwrap(), user_tunnel_block: "10.5.0.0/24".parse().unwrap(), multicastgroup_block: "224.2.0.0/4".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 10000, }; diff --git a/smartcontract/cli/src/globalconfig/get.rs b/smartcontract/cli/src/globalconfig/get.rs index ed41da861..e948523c2 100644 --- a/smartcontract/cli/src/globalconfig/get.rs +++ b/smartcontract/cli/src/globalconfig/get.rs @@ -70,6 +70,7 @@ mod tests { device_tunnel_block: "10.1.0.0/24".parse().unwrap(), user_tunnel_block: "10.5.0.0/24".parse().unwrap(), multicastgroup_block: "224.2.0.0/4".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 10000, }; diff --git a/smartcontract/cli/src/globalconfig/set.rs b/smartcontract/cli/src/globalconfig/set.rs index 9515fcbc4..282c571e4 100644 --- a/smartcontract/cli/src/globalconfig/set.rs +++ b/smartcontract/cli/src/globalconfig/set.rs @@ -29,6 +29,9 @@ pub struct SetGlobalConfigCliCommand { /// Next BGP community value to assign #[arg(long)] pub next_bgp_community: Option, + /// Multicast publisher block in CIDR format + #[arg(long)] + multicast_publisher_block: Option, } impl SetGlobalConfigCliCommand { @@ -54,6 +57,7 @@ impl SetGlobalConfigCliCommand { user_tunnel_block: self.user_tunnel_block, multicastgroup_block: self.multicastgroup_block, next_bgp_community: self.next_bgp_community, + multicast_publisher_block: self.multicast_publisher_block, })?; writeln!(out, "Signature: {signature}",)?; @@ -95,6 +99,7 @@ mod tests { device_tunnel_block: "10.20.0.0/16".parse().ok(), user_tunnel_block: "10.10.0.0/16".parse().ok(), multicastgroup_block: "224.2.0.0/4".parse().ok(), + multicast_publisher_block: None, next_bgp_community: None, })) .returning(move |_| Ok(signature)); @@ -108,6 +113,7 @@ mod tests { device_tunnel_block: "10.20.0.0/16".parse().ok(), user_tunnel_block: "10.10.0.0/16".parse().ok(), multicastgroup_block: "224.2.0.0/4".parse().ok(), + multicast_publisher_block: None, next_bgp_community: None, } .execute(&client, &mut output1); @@ -126,6 +132,7 @@ mod tests { device_tunnel_block: None, user_tunnel_block: None, multicastgroup_block: None, + multicast_publisher_block: None, next_bgp_community: None, })) .returning(move |_| Ok(signature)); @@ -136,6 +143,7 @@ mod tests { device_tunnel_block: None, user_tunnel_block: None, multicastgroup_block: None, + multicast_publisher_block: None, next_bgp_community: None, } .execute(&client, &mut output2); @@ -162,6 +170,7 @@ mod tests { device_tunnel_block: None, user_tunnel_block: None, multicastgroup_block: None, + multicast_publisher_block: None, next_bgp_community: None, })) .returning(move |_| { @@ -177,6 +186,7 @@ mod tests { device_tunnel_block: None, user_tunnel_block: None, multicastgroup_block: None, + multicast_publisher_block: None, next_bgp_community: None, } .execute(&client, &mut output); diff --git a/smartcontract/cli/src/resource/allocate.rs b/smartcontract/cli/src/resource/allocate.rs index 803a2d669..bc3b56633 100644 --- a/smartcontract/cli/src/resource/allocate.rs +++ b/smartcontract/cli/src/resource/allocate.rs @@ -36,6 +36,7 @@ impl From for AllocateResourceCommand { ResourceType::DeviceTunnelBlock | ResourceType::UserTunnelBlock | ResourceType::MulticastGroupBlock + | ResourceType::MulticastPublisherBlock | ResourceType::DzPrefixBlock => { IdOrIp::Ip(x.parse::().expect("Failed to parse IP address")) } diff --git a/smartcontract/cli/src/resource/deallocate.rs b/smartcontract/cli/src/resource/deallocate.rs index 98a6704d9..fbfe2b520 100644 --- a/smartcontract/cli/src/resource/deallocate.rs +++ b/smartcontract/cli/src/resource/deallocate.rs @@ -36,6 +36,7 @@ impl From for DeallocateResourceCommand { ResourceType::DeviceTunnelBlock | ResourceType::UserTunnelBlock | ResourceType::MulticastGroupBlock + | ResourceType::MulticastPublisherBlock | ResourceType::DzPrefixBlock => IdOrIp::Ip( cmd.value .parse::() diff --git a/smartcontract/cli/src/resource/mod.rs b/smartcontract/cli/src/resource/mod.rs index 1d08dd93e..b1ef5f12a 100644 --- a/smartcontract/cli/src/resource/mod.rs +++ b/smartcontract/cli/src/resource/mod.rs @@ -13,6 +13,7 @@ pub enum ResourceType { DeviceTunnelBlock, UserTunnelBlock, MulticastGroupBlock, + MulticastPublisherBlock, DzPrefixBlock, TunnelIds, LinkIds, @@ -28,6 +29,7 @@ pub fn resource_type_from( ResourceType::DeviceTunnelBlock => SdkResourceType::DeviceTunnelBlock, ResourceType::UserTunnelBlock => SdkResourceType::UserTunnelBlock, ResourceType::MulticastGroupBlock => SdkResourceType::MulticastGroupBlock, + ResourceType::MulticastPublisherBlock => SdkResourceType::MulticastPublisherBlock, ResourceType::DzPrefixBlock => { let pk = associated_pubkey.unwrap_or_default(); let idx = index.unwrap_or(0); @@ -88,6 +90,12 @@ mod tests { assert_eq!(result, SdkResourceType::MulticastGroupBlock); } + #[test] + fn test_multicast_publisher_block() { + let result = resource_type_from(ResourceType::MulticastPublisherBlock, None, None); + assert_eq!(result, SdkResourceType::MulticastPublisherBlock); + } + #[test] fn test_dz_prefix_block_with_values() { let pk = Pubkey::new_unique(); diff --git a/smartcontract/programs/doublezero-serviceability/src/instructions.rs b/smartcontract/programs/doublezero-serviceability/src/instructions.rs index 48ae139c2..c1d248b4e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/instructions.rs +++ b/smartcontract/programs/doublezero-serviceability/src/instructions.rs @@ -565,6 +565,7 @@ mod tests { device_tunnel_block: "1.2.3.4/1".parse().unwrap(), user_tunnel_block: "1.2.3.4/1".parse().unwrap(), multicastgroup_block: "1.2.3.4/1".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), "SetGlobalConfig", @@ -762,7 +763,10 @@ mod tests { "CloseAccountLink", ); test_instruction( - DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { dz_prefix_count: 0 }), + DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { + dz_prefix_count: 0, + multicast_publisher_count: 0, + }), "CloseAccountUser", ); test_instruction( diff --git a/smartcontract/programs/doublezero-serviceability/src/pda.rs b/smartcontract/programs/doublezero-serviceability/src/pda.rs index df29af084..a0fae8fcb 100644 --- a/smartcontract/programs/doublezero-serviceability/src/pda.rs +++ b/smartcontract/programs/doublezero-serviceability/src/pda.rs @@ -6,9 +6,9 @@ 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_LINK, SEED_LINK_IDS, - SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, SEED_PREFIX, - SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, SEED_TUNNEL_IDS, SEED_USER, - SEED_USER_TUNNEL_BLOCK, + SEED_LOCATION, SEED_MULTICASTGROUP_BLOCK, SEED_MULTICAST_GROUP, + SEED_MULTICAST_PUBLISHER_BLOCK, SEED_PREFIX, SEED_PROGRAM_CONFIG, SEED_SEGMENT_ROUTING_IDS, + SEED_TUNNEL_IDS, SEED_USER, SEED_USER_TUNNEL_BLOCK, }, state::user::UserType, }; @@ -111,6 +111,13 @@ pub fn get_resource_extension_pda( Pubkey::find_program_address(&[SEED_PREFIX, SEED_MULTICASTGROUP_BLOCK], program_id); (pda, bump_seed, SEED_MULTICASTGROUP_BLOCK) } + crate::resource::ResourceType::MulticastPublisherBlock => { + let (pda, bump_seed) = Pubkey::find_program_address( + &[SEED_PREFIX, SEED_MULTICAST_PUBLISHER_BLOCK], + program_id, + ); + (pda, bump_seed, SEED_MULTICAST_PUBLISHER_BLOCK) + } crate::resource::ResourceType::DzPrefixBlock(ref associated_pk, index) => { let (pda, bump_seed) = Pubkey::find_program_address( &[ diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs index a5d69a864..efbf7edd4 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/globalconfig/set.rs @@ -30,19 +30,21 @@ pub struct SetGlobalConfigArgs { pub user_tunnel_block: NetworkV4, pub multicastgroup_block: NetworkV4, pub next_bgp_community: Option, + pub multicast_publisher_block: NetworkV4, } impl fmt::Debug for SetGlobalConfigArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "local_asn: {}, remote_asn: {}, tunnel_block: {}, user _block: {}, multicastgroup_block: {}, next_bgp_community: {:?}", + "local_asn: {}, remote_asn: {}, tunnel_block: {}, user _block: {}, multicastgroup_block: {}, next_bgp_community: {:?}, multicast_publisher_block: {}", self.local_asn, self.remote_asn, &self.device_tunnel_block, &self.user_tunnel_block, &self.multicastgroup_block, self.next_bgp_community, + &self.multicast_publisher_block, ) } } @@ -61,6 +63,7 @@ pub fn process_set_globalconfig( let multicastgroup_block_account = next_account_info(accounts_iter)?; let link_ids_account = next_account_info(accounts_iter)?; let segment_routing_ids_account = next_account_info(accounts_iter)?; + let multicast_publisher_block_account = next_account_info(accounts_iter)?; let payer_account = next_account_info(accounts_iter)?; let system_program = next_account_info(accounts_iter)?; @@ -98,6 +101,8 @@ pub fn process_set_globalconfig( get_resource_extension_pda(program_id, ResourceType::UserTunnelBlock); let (multicastgroup_block_pda, _, _) = get_resource_extension_pda(program_id, ResourceType::MulticastGroupBlock); + let (multicast_publisher_block_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::MulticastPublisherBlock); assert_eq!( device_tunnel_block_account.key, &device_tunnel_block_pda, @@ -114,6 +119,11 @@ pub fn process_set_globalconfig( "Invalid Multicast Group Block PubKey" ); + assert_eq!( + multicast_publisher_block_account.key, &multicast_publisher_block_pda, + "Invalid Multicast Publisher Block PubKey" + ); + let next_bgp_community = if let Some(val) = value.next_bgp_community { val } else if pda_account.try_borrow_data()?.is_empty() { @@ -132,6 +142,7 @@ pub fn process_set_globalconfig( user_tunnel_block: value.user_tunnel_block, multicastgroup_block: value.multicastgroup_block, next_bgp_community, + multicast_publisher_block: value.multicast_publisher_block, }; if pda_account.data_is_empty() { @@ -198,6 +209,16 @@ pub fn process_set_globalconfig( accounts, ResourceType::SegmentRoutingIds, )?; + + create_resource( + program_id, + multicast_publisher_block_account, + None, + pda_account, + payer_account, + accounts, + ResourceType::MulticastPublisherBlock, + )?; } else { let old_data = GlobalConfig::try_from(pda_account)?; if old_data.device_tunnel_block != data.device_tunnel_block { @@ -209,6 +230,9 @@ pub fn process_set_globalconfig( if old_data.multicastgroup_block != data.multicastgroup_block { return Err(DoubleZeroError::ImmutableField.into()); } + if old_data.multicast_publisher_block != data.multicast_publisher_block { + return Err(DoubleZeroError::ImmutableField.into()); + } try_acc_write(&data, pda_account, payer_account, accounts)?; } diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs index 1f515158e..a4ffe6ec6 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/resource/mod.rs @@ -44,6 +44,9 @@ pub fn get_resource_extension_range( ResourceType::MulticastGroupBlock => { ResourceExtensionRange::IpBlock(globalconfig.multicastgroup_block, 1) } + ResourceType::MulticastPublisherBlock => { + ResourceExtensionRange::IpBlock(globalconfig.multicast_publisher_block, 1) + } ResourceType::DzPrefixBlock(_, index) => { assert!( device.is_some(), diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/activate.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/activate.rs index 19e54b450..536dea10e 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/activate.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/activate.rs @@ -57,11 +57,12 @@ pub fn process_activate_user( // Optional: ResourceExtension accounts for on-chain allocation (before payer) // Account layout WITH ResourceExtension (dz_prefix_count > 0): - // [user, accesspass, globalstate, global_resource_ext, device_tunnel_ids_ext, dz_prefix_ext_0..N, payer, system] + // [user, accesspass, globalstate, global_resource_ext, multicast_publisher_block_ext, device_tunnel_ids_ext, dz_prefix_ext_0..N, payer, system] // Account layout WITHOUT (legacy, dz_prefix_count == 0): // [user, accesspass, globalstate, payer, system] let resource_extension_accounts = if value.dz_prefix_count > 0 { let global_resource_ext = next_account_info(accounts_iter)?; // UserTunnelBlock + let multicast_publisher_block_ext = next_account_info(accounts_iter)?; // MulticastPublisherBlock let device_tunnel_ids_ext = next_account_info(accounts_iter)?; // TunnelIds // Collect DzPrefixBlock accounts based on dz_prefix_count from args @@ -72,6 +73,7 @@ pub fn process_activate_user( Some(( global_resource_ext, + multicast_publisher_block_ext, device_tunnel_ids_ext, dz_prefix_accounts, )) @@ -135,8 +137,12 @@ pub fn process_activate_user( } // Allocate resources from ResourceExtension or use provided values - if let Some((global_resource_ext, device_tunnel_ids_ext, dz_prefix_accounts)) = - resource_extension_accounts + if let Some(( + global_resource_ext, + multicast_publisher_block_ext, + device_tunnel_ids_ext, + dz_prefix_accounts, + )) = resource_extension_accounts { // Validate global_resource_ext (UserTunnelBlock) assert_eq!( @@ -159,6 +165,27 @@ pub fn process_activate_user( "Invalid ResourceExtension PDA for UserTunnelBlock" ); + // Validate multicast_publisher_block_ext (MulticastPublisherBlock) + assert_eq!( + multicast_publisher_block_ext.owner, program_id, + "Invalid ResourceExtension Account Owner for MulticastPublisherBlock" + ); + assert!( + multicast_publisher_block_ext.is_writable, + "ResourceExtension Account for MulticastPublisherBlock is not writable" + ); + assert!( + !multicast_publisher_block_ext.data_is_empty(), + "ResourceExtension Account for MulticastPublisherBlock is empty" + ); + + let (expected_multicast_publisher_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::MulticastPublisherBlock); + assert_eq!( + multicast_publisher_block_ext.key, &expected_multicast_publisher_pda, + "Invalid ResourceExtension PDA for MulticastPublisherBlock" + ); + // Validate device_tunnel_ids_ext (TunnelIds) assert_eq!( device_tunnel_ids_ext.owner, program_id, @@ -242,22 +269,36 @@ pub fn process_activate_user( // - dz_ip == client_ip: Multicast user that didn't need dz_ip before (no publishers) // If dz_ip is already a dedicated IP (not UNSPECIFIED or client_ip), keep it if need_dz_ip && (user.dz_ip == Ipv4Addr::UNSPECIFIED || user.dz_ip == user.client_ip) { - // Try to allocate from each DzPrefixBlock until one succeeds - let mut allocated_dz_ip = None; - for dz_prefix_account in dz_prefix_accounts.iter() { - let mut buffer = dz_prefix_account.data.borrow_mut(); + let allocated_dz_ip = if user.user_type == UserType::Multicast + && !user.publishers.is_empty() + { + // Multicast publishers: allocate from global MulticastPublisherBlock + let mut buffer = multicast_publisher_block_ext.data.borrow_mut(); let mut resource = ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; - if let Ok(ip) = resource + resource .allocate(1) .and_then(|v| v.as_ip().ok_or(DoubleZeroError::InvalidArgument)) - { - allocated_dz_ip = Some(ip.ip()); - break; + .map(|ip| ip.ip())? + } else { + // EdgeFiltering/IBRL: allocate from device DzPrefixBlock + let mut allocated_ip = None; + for dz_prefix_account in dz_prefix_accounts.iter() { + let mut buffer = dz_prefix_account.data.borrow_mut(); + let mut resource = ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; + + if let Ok(ip) = resource + .allocate(1) + .and_then(|v| v.as_ip().ok_or(DoubleZeroError::InvalidArgument)) + { + allocated_ip = Some(ip.ip()); + break; + } } - } + allocated_ip.ok_or(DoubleZeroError::AllocationFailed)? + }; - user.dz_ip = allocated_dz_ip.ok_or(DoubleZeroError::AllocationFailed)?; + user.dz_ip = allocated_dz_ip; } else if !need_dz_ip && user.dz_ip == Ipv4Addr::UNSPECIFIED { // First activation for user that doesn't need dz_ip: use client_ip user.dz_ip = user.client_ip; diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/user/closeaccount.rs b/smartcontract/programs/doublezero-serviceability/src/processors/user/closeaccount.rs index 4cc590870..9371c8fa3 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/user/closeaccount.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/user/closeaccount.rs @@ -27,11 +27,18 @@ pub struct UserCloseAccountArgs { /// When 0, legacy behavior is used (no deallocation). When > 0, on-chain deallocation is used. #[incremental(default = 0)] pub dz_prefix_count: u8, + /// Whether MulticastPublisherBlock account is passed (1 = yes, 0 = no). + #[incremental(default = 0)] + pub multicast_publisher_count: u8, } impl fmt::Debug for UserCloseAccountArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "dz_prefix_count: {}", self.dz_prefix_count) + write!( + f, + "dz_prefix_count: {}, multicast_publisher_count: {}", + self.dz_prefix_count, self.multicast_publisher_count + ) } } @@ -49,11 +56,19 @@ pub fn process_closeaccount_user( // Optional: ResourceExtension accounts for on-chain deallocation (before payer) // Account layout WITH ResourceExtension (dz_prefix_count > 0): - // [user, owner, device, globalstate, global_resource_ext, device_tunnel_ids_ext, dz_prefix_ext_0..N, payer, system] + // [user, owner, device, globalstate, global_resource_ext, multicast_publisher_block_ext?, device_tunnel_ids_ext, dz_prefix_ext_0..N, payer, system] // Account layout WITHOUT (legacy, dz_prefix_count == 0): // [user, owner, device, globalstate, payer, system] let resource_extension_accounts = if value.dz_prefix_count > 0 { let global_resource_ext = next_account_info(accounts_iter)?; // UserTunnelBlock + + // Optional MulticastPublisherBlock account + let multicast_publisher_block_ext = if value.multicast_publisher_count > 0 { + Some(next_account_info(accounts_iter)?) + } else { + None + }; + let device_tunnel_ids_ext = next_account_info(accounts_iter)?; // TunnelIds // Collect DzPrefixBlock accounts based on dz_prefix_count from args @@ -64,6 +79,7 @@ pub fn process_closeaccount_user( Some(( global_resource_ext, + multicast_publisher_block_ext, device_tunnel_ids_ext, dz_prefix_accounts, )) @@ -128,8 +144,12 @@ pub fn process_closeaccount_user( // Deallocate resources from ResourceExtension if accounts provided // Deallocation is idempotent - safe to call even if resources weren't allocated - if let Some((global_resource_ext, device_tunnel_ids_ext, dz_prefix_accounts)) = - resource_extension_accounts + if let Some(( + global_resource_ext, + multicast_publisher_block_ext, + device_tunnel_ids_ext, + dz_prefix_accounts, + )) = resource_extension_accounts { // Validate global_resource_ext (UserTunnelBlock) assert_eq!( @@ -152,6 +172,29 @@ pub fn process_closeaccount_user( "Invalid ResourceExtension PDA for UserTunnelBlock" ); + // Validate multicast_publisher_block_ext (MulticastPublisherBlock) if provided + if let Some(multicast_publisher_ext) = multicast_publisher_block_ext { + assert_eq!( + multicast_publisher_ext.owner, program_id, + "Invalid ResourceExtension Account Owner for MulticastPublisherBlock" + ); + assert!( + multicast_publisher_ext.is_writable, + "ResourceExtension Account for MulticastPublisherBlock is not writable" + ); + assert!( + !multicast_publisher_ext.data_is_empty(), + "ResourceExtension Account for MulticastPublisherBlock is empty" + ); + + let (expected_multicast_publisher_pda, _, _) = + get_resource_extension_pda(program_id, ResourceType::MulticastPublisherBlock); + assert_eq!( + multicast_publisher_ext.key, &expected_multicast_publisher_pda, + "Invalid ResourceExtension PDA for MulticastPublisherBlock" + ); + } + // Validate device_tunnel_ids_ext (TunnelIds) assert_eq!( device_tunnel_ids_ext.owner, program_id, @@ -225,24 +268,42 @@ pub fn process_closeaccount_user( msg!("Deallocated tunnel_id {}: {}", user.tunnel_id, _deallocated); } - // Deallocate dz_ip from device DzPrefixBlock (only if allocated, not client_ip) - // Try to deallocate from each DzPrefixBlock until one succeeds - // dz_ip is allocated when != client_ip and is a valid global unicast address + // Deallocate dz_ip (try MulticastPublisherBlock first, then DzPrefixBlock) + // Only deallocate if dz_ip is allocated (not client_ip and not UNSPECIFIED) if user.dz_ip != user.client_ip && user.dz_ip != Ipv4Addr::UNSPECIFIED { if let Ok(dz_ip_net) = NetworkV4::new(user.dz_ip, 32) { - for dz_prefix_account in dz_prefix_accounts.iter() { - let mut buffer = dz_prefix_account.data.borrow_mut(); + let mut deallocated = false; + + // Try MulticastPublisherBlock first (for publishers) + if let Some(multicast_publisher_ext) = multicast_publisher_block_ext { + let mut buffer = multicast_publisher_ext.data.borrow_mut(); let mut resource = ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; - let deallocated = resource.deallocate(&IdOrIp::Ip(dz_ip_net)); + deallocated = resource.deallocate(&IdOrIp::Ip(dz_ip_net)); #[cfg(test)] msg!( - "Deallocated dz_ip {} from {:?}: {}", + "Deallocated dz_ip {} from MulticastPublisherBlock: {}", dz_ip_net, - dz_prefix_account.key, deallocated ); - if deallocated { - break; // Successfully deallocated + } + + // Fall back to DzPrefixBlock if not in MulticastPublisherBlock + if !deallocated { + for dz_prefix_account in dz_prefix_accounts.iter() { + let mut buffer = dz_prefix_account.data.borrow_mut(); + let mut resource = + ResourceExtensionBorrowed::inplace_from(&mut buffer[..])?; + deallocated = resource.deallocate(&IdOrIp::Ip(dz_ip_net)); + #[cfg(test)] + msg!( + "Deallocated dz_ip {} from DzPrefixBlock {:?}: {}", + dz_ip_net, + dz_prefix_account.key, + deallocated + ); + if deallocated { + break; // Successfully deallocated + } } } } @@ -408,6 +469,7 @@ mod tests { &accounts, &UserCloseAccountArgs { dz_prefix_count: 0, // legacy path - no ResourceExtension accounts + multicast_publisher_count: 0, }, ); diff --git a/smartcontract/programs/doublezero-serviceability/src/resource.rs b/smartcontract/programs/doublezero-serviceability/src/resource.rs index 797883f52..fdd49016d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/resource.rs +++ b/smartcontract/programs/doublezero-serviceability/src/resource.rs @@ -9,6 +9,7 @@ pub enum ResourceType { DeviceTunnelBlock, UserTunnelBlock, MulticastGroupBlock, + MulticastPublisherBlock, DzPrefixBlock(Pubkey, usize), TunnelIds(Pubkey, usize), LinkIds, diff --git a/smartcontract/programs/doublezero-serviceability/src/seeds.rs b/smartcontract/programs/doublezero-serviceability/src/seeds.rs index 7dc5c492e..9db88d56c 100644 --- a/smartcontract/programs/doublezero-serviceability/src/seeds.rs +++ b/smartcontract/programs/doublezero-serviceability/src/seeds.rs @@ -13,6 +13,7 @@ pub const SEED_CONTRIBUTOR: &[u8] = b"contributor"; pub const SEED_DEVICE_TUNNEL_BLOCK: &[u8] = b"devicetunnelblock"; pub const SEED_USER_TUNNEL_BLOCK: &[u8] = b"usertunnelblock"; pub const SEED_MULTICASTGROUP_BLOCK: &[u8] = b"multicastgroupblock"; +pub const SEED_MULTICAST_PUBLISHER_BLOCK: &[u8] = b"multicastpublisherblock"; pub const SEED_DZ_PREFIX_BLOCK: &[u8] = b"dzprefixblock"; pub const SEED_TUNNEL_IDS: &[u8] = b"tunnelids"; pub const SEED_LINK_IDS: &[u8] = b"linkids"; diff --git a/smartcontract/programs/doublezero-serviceability/src/state/globalconfig.rs b/smartcontract/programs/doublezero-serviceability/src/state/globalconfig.rs index 2023d0187..d83a87179 100644 --- a/smartcontract/programs/doublezero-serviceability/src/state/globalconfig.rs +++ b/smartcontract/programs/doublezero-serviceability/src/state/globalconfig.rs @@ -10,27 +10,29 @@ use std::fmt; #[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct GlobalConfig { - pub account_type: AccountType, // 1 - pub owner: Pubkey, // 32 - pub bump_seed: u8, // 1 - pub local_asn: u32, // 4 - pub remote_asn: u32, // 4 - pub device_tunnel_block: NetworkV4, // 5 - pub user_tunnel_block: NetworkV4, // 5 - pub multicastgroup_block: NetworkV4, // 5 - pub next_bgp_community: u16, // 2 + pub account_type: AccountType, // 1 + pub owner: Pubkey, // 32 + pub bump_seed: u8, // 1 + pub local_asn: u32, // 4 + pub remote_asn: u32, // 4 + pub device_tunnel_block: NetworkV4, // 5 + pub user_tunnel_block: NetworkV4, // 5 + pub multicastgroup_block: NetworkV4, // 5 + pub next_bgp_community: u16, // 2 + pub multicast_publisher_block: NetworkV4, // 5 } impl fmt::Display for GlobalConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "account_type: {}, owner: {}, local_asn: {}, remote_asn: {}, device_tunnel_block: {}, user_tunnel_block: {}, multicastgroup_block: {}, next_bgp_community: {}", + "account_type: {}, owner: {}, local_asn: {}, remote_asn: {}, device_tunnel_block: {}, user_tunnel_block: {}, multicastgroup_block: {}, next_bgp_community: {}, multicast_publisher_block: {}", self.account_type, self.owner, self.local_asn, self.remote_asn, &self.device_tunnel_block, &self.user_tunnel_block, &self.multicastgroup_block, self.next_bgp_community, + &self.multicast_publisher_block, ) } } @@ -50,6 +52,7 @@ impl TryFrom<&[u8]> for GlobalConfig { multicastgroup_block: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), next_bgp_community: BorshDeserialize::deserialize(&mut data) .unwrap_or(BGP_COMMUNITY_MIN), + multicast_publisher_block: BorshDeserialize::deserialize(&mut data).unwrap_or_default(), }; if out.account_type != AccountType::GlobalConfig { @@ -78,7 +81,7 @@ impl TryFrom<&AccountInfo<'_>> for GlobalConfig { impl GlobalConfig { pub fn size(&self) -> usize { - 1 + 32 + 1 + 4 + 4 + 5 + 5 + 5 + 2 + 1 + 32 + 1 + 4 + 4 + 5 + 5 + 5 + 2 + 5 } } @@ -131,6 +134,7 @@ mod tests { assert_eq!(val.user_tunnel_block, NetworkV4::default()); assert_eq!(val.multicastgroup_block, NetworkV4::default()); assert_eq!(val.next_bgp_community, BGP_COMMUNITY_MIN); + assert_eq!(val.multicast_publisher_block, NetworkV4::default()); } #[test] @@ -145,6 +149,7 @@ mod tests { user_tunnel_block: "10.0.0.2/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/4".parse().unwrap(), next_bgp_community: BGP_COMMUNITY_MIN, + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), }; let data = borsh::to_vec(&val).unwrap(); @@ -164,6 +169,10 @@ mod tests { assert_eq!(val.user_tunnel_block, val2.user_tunnel_block); assert_eq!(val.multicastgroup_block, val2.multicastgroup_block); assert_eq!(val.next_bgp_community, val2.next_bgp_community); + assert_eq!( + val.multicast_publisher_block, + val2.multicast_publisher_block + ); assert_eq!( data.len(), borsh::object_length(&val).unwrap(), @@ -183,6 +192,7 @@ mod tests { user_tunnel_block: "10.0.0.2/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/4".parse().unwrap(), next_bgp_community: BGP_COMMUNITY_MIN, + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), }; let err = val.validate(); assert!(err.is_err()); @@ -201,6 +211,7 @@ mod tests { user_tunnel_block: "10.0.0.2/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/4".parse().unwrap(), next_bgp_community: BGP_COMMUNITY_MIN, + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), }; let err_zero = val_zero.validate(); assert!(err_zero.is_err()); @@ -227,6 +238,7 @@ mod tests { user_tunnel_block: "10.0.0.2/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/4".parse().unwrap(), next_bgp_community: BGP_COMMUNITY_MIN, + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), }; let err_zero = val_zero.validate(); assert!(err_zero.is_err()); 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 cc655325e..3bc64f4bc 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/accesspass_allow_multiple_ip.rs @@ -71,6 +71,7 @@ async fn test_accesspass_allow_multiple_ip() { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), user_tunnel_block: "9.0.0.0/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/16".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -493,7 +494,8 @@ async fn test_accesspass_allow_multiple_ip() { recent_blockhash, program_id, DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { - dz_prefix_count: 0, // legacy path - no ResourceExtension accounts + dz_prefix_count: 0, + multicast_publisher_count: 0, // legacy path - no ResourceExtension accounts }), vec![ AccountMeta::new(user_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs index 669f0bf75..914f67a42 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_test.rs @@ -81,6 +81,7 @@ async fn test_device() { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), // Private tunnel block user_tunnel_block: "10.0.0.0/24".parse().unwrap(), // Private tunnel block multicastgroup_block: "224.0.0.0/16".parse().unwrap(), // Multicast block + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -745,6 +746,7 @@ async fn setup_program_with_location_and_exchange( 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ 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 0c0607146..9f4206eb2 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/device_update_location_test.rs @@ -71,6 +71,7 @@ async fn device_update_location_test() { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), // Private tunnel block user_tunnel_block: "10.0.0.0/24".parse().unwrap(), // Private tunnel block multicastgroup_block: "224.0.0.0/16".parse().unwrap(), // Multicast block + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs index b298cbb85..f72dae17a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_setdevice.rs @@ -64,6 +64,7 @@ async fn exchange_setdevice() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs index 8158f3ca0..9d805af44 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/exchange_test.rs @@ -63,6 +63,7 @@ async fn test_exchange() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -287,6 +288,7 @@ async fn test_exchange_owner_and_foundation_can_update_status() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -438,6 +440,7 @@ async fn test_exchange_bgp_community_autoassignment() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -592,6 +595,7 @@ async fn test_exchange_bgp_community_autoassignment() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: Some(10999), }), vec![ @@ -736,6 +740,7 @@ async fn test_suspend_exchange_from_suspended_fails() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs index bc4fabfcc..3e108ff9b 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/global_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/global_test.rs @@ -81,6 +81,7 @@ async fn test_doublezero_program() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs index 5013abd41..da22affe5 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/interface_test.rs @@ -76,6 +76,7 @@ async fn test_device_interfaces() { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), // Private tunnel block user_tunnel_block: "10.0.0.0/24".parse().unwrap(), // Private tunnel block multicastgroup_block: "224.0.0.0/16".parse().unwrap(), // Multicast block + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs index 9fe8b2317..67f0bf1b4 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_dzx_test.rs @@ -72,6 +72,7 @@ async fn test_dzx_link() { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), user_tunnel_block: "10.0.0.0/24".parse().unwrap(), multicastgroup_block: "10.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs index d0346d408..d8583fa41 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/link_wan_test.rs @@ -71,6 +71,7 @@ async fn test_wan_link() { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), user_tunnel_block: "10.0.0.0/24".parse().unwrap(), multicastgroup_block: "10.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs index 8f4e2e632..f7307c46f 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/multicastgroup_subscribe_test.rs @@ -91,6 +91,7 @@ async fn setup_fixture() -> TestFixture { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs index 39ab344ac..ee86c4012 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/test_helpers.rs @@ -383,6 +383,7 @@ pub async fn setup_program_with_globalconfig() -> (BanksClient, Keypair, Pubkey, device_tunnel_block: "10.100.0.0/24".parse().unwrap(), user_tunnel_block: "10.200.0.0/24".parse().unwrap(), multicastgroup_block: "239.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs index 44c44c7e0..6da1dab01 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_migration.rs @@ -66,6 +66,7 @@ async fn test_user_migration() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs index ff1888e18..2d6370e14 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_old_test.rs @@ -70,6 +70,7 @@ async fn test_old_user() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -487,7 +488,8 @@ async fn test_old_user() { recent_blockhash, program_id, DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { - dz_prefix_count: 0, // legacy path - no ResourceExtension accounts + dz_prefix_count: 0, + multicast_publisher_count: 0, // legacy path - no ResourceExtension accounts }), vec![ AccountMeta::new(user_pubkey, false), 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 8a3c2d6e5..ff5a5d589 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_onchain_allocation_test.rs @@ -124,6 +124,7 @@ async fn setup_user_onchain_allocation_test( device_tunnel_block: "10.100.0.0/24".parse().unwrap(), user_tunnel_block: "169.254.0.0/24".parse().unwrap(), // Link-local for user tunnel_net multicastgroup_block: "239.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -590,7 +591,8 @@ async fn test_closeaccount_user_with_deallocation() { recent_blockhash, program_id, DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { - dz_prefix_count: 1, // 1 DzPrefixBlock account provided + dz_prefix_count: 1, + multicast_publisher_count: 0, // 1 DzPrefixBlock account provided }), vec![ AccountMeta::new(user_pubkey, false), diff --git a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs index 82cadaec2..f6934a06a 100644 --- a/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs +++ b/smartcontract/programs/doublezero-serviceability/tests/user_tests.rs @@ -70,6 +70,7 @@ async fn test_user() { 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: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ @@ -487,7 +488,8 @@ async fn test_user() { recent_blockhash, program_id, DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { - dz_prefix_count: 0, // legacy path - no ResourceExtension accounts + dz_prefix_count: 0, + multicast_publisher_count: 0, // legacy path - no ResourceExtension accounts }), vec![ AccountMeta::new(user_pubkey, false), @@ -549,6 +551,7 @@ async fn test_user_ban_requires_pendingban() { 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/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs index 55ff76d09..e74329335 100644 --- a/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs +++ b/smartcontract/programs/doublezero-telemetry/tests/test_helpers.rs @@ -792,6 +792,7 @@ impl ServiceabilityProgramHelper { 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/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: None, }), vec![ diff --git a/smartcontract/sdk/rs/src/commands/device/activate.rs b/smartcontract/sdk/rs/src/commands/device/activate.rs index f65652ef4..f7fce2c55 100644 --- a/smartcontract/sdk/rs/src/commands/device/activate.rs +++ b/smartcontract/sdk/rs/src/commands/device/activate.rs @@ -110,6 +110,7 @@ mod tests { device_tunnel_block: "1.0.0.0/24".parse().unwrap(), user_tunnel_block: "2.0.0.0/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 0, })) }); diff --git a/smartcontract/sdk/rs/src/commands/device/sethealth.rs b/smartcontract/sdk/rs/src/commands/device/sethealth.rs index 632ed2358..af5bc0504 100644 --- a/smartcontract/sdk/rs/src/commands/device/sethealth.rs +++ b/smartcontract/sdk/rs/src/commands/device/sethealth.rs @@ -98,6 +98,7 @@ mod tests { device_tunnel_block: "10.0.0.0/24".parse().unwrap(), user_tunnel_block: "10.1.0.0/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 1, })) }); diff --git a/smartcontract/sdk/rs/src/commands/device/update.rs b/smartcontract/sdk/rs/src/commands/device/update.rs index be46f6268..5f819239a 100644 --- a/smartcontract/sdk/rs/src/commands/device/update.rs +++ b/smartcontract/sdk/rs/src/commands/device/update.rs @@ -169,6 +169,7 @@ mod tests { device_tunnel_block: "1.0.0.0/24".parse().unwrap(), user_tunnel_block: "2.0.0.0/24".parse().unwrap(), multicastgroup_block: "224.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), next_bgp_community: 0, })) }); diff --git a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs index 0a5905667..d3dea4c17 100644 --- a/smartcontract/sdk/rs/src/commands/globalconfig/set.rs +++ b/smartcontract/sdk/rs/src/commands/globalconfig/set.rs @@ -17,6 +17,7 @@ pub struct SetGlobalConfigCommand { pub user_tunnel_block: Option, pub multicastgroup_block: Option, pub next_bgp_community: Option, + pub multicast_publisher_block: Option, } impl SetGlobalConfigCommand { @@ -39,6 +40,10 @@ impl SetGlobalConfigCommand { get_resource_extension_pda(&client.get_program_id(), ResourceType::LinkIds); let (segment_routing_ids_pda, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::SegmentRoutingIds); + let (multicast_publisher_block_pda, _, _) = get_resource_extension_pda( + &client.get_program_id(), + ResourceType::MulticastPublisherBlock, + ); client.execute_transaction( DoubleZeroInstruction::SetGlobalConfig(set_config_args), @@ -50,6 +55,7 @@ impl SetGlobalConfigCommand { 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), ], ) } @@ -67,6 +73,7 @@ impl SetGlobalConfigCommand { user_tunnel_block: None, multicastgroup_block: None, next_bgp_community: None, + multicast_publisher_block: None, }, _, ) => Err(eyre::eyre!( @@ -80,6 +87,7 @@ impl SetGlobalConfigCommand { user_tunnel_block: Some(user_tunnel_block), multicastgroup_block: Some(multicastgroup_block), next_bgp_community, + multicast_publisher_block: Some(multicast_publisher_block), }, _, ) => Ok(SetGlobalConfigArgs { @@ -89,6 +97,7 @@ impl SetGlobalConfigCommand { user_tunnel_block: *user_tunnel_block, multicastgroup_block: *multicastgroup_block, next_bgp_community: *next_bgp_community, + multicast_publisher_block: *multicast_publisher_block, }), (_, None) => Err(eyre::eyre!("Invalid SetGlobalConfigCommand; incomplete set command with no valid config to update")), (set_config_command, Some((_, existing_config))) => Ok(SetGlobalConfigArgs { @@ -98,6 +107,9 @@ impl SetGlobalConfigCommand { user_tunnel_block: set_config_command.user_tunnel_block.unwrap_or(existing_config.user_tunnel_block), multicastgroup_block: set_config_command.multicastgroup_block.unwrap_or(existing_config.multicastgroup_block), next_bgp_community: set_config_command.next_bgp_community, + multicast_publisher_block: set_config_command + .multicast_publisher_block + .unwrap_or(existing_config.multicast_publisher_block), }), } } diff --git a/smartcontract/sdk/rs/src/commands/user/activate.rs b/smartcontract/sdk/rs/src/commands/user/activate.rs index 5f4903d5f..94dcec436 100644 --- a/smartcontract/sdk/rs/src/commands/user/activate.rs +++ b/smartcontract/sdk/rs/src/commands/user/activate.rs @@ -72,6 +72,12 @@ impl ActivateUserCommand { let (global_resource_ext, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::UserTunnelBlock); + // Global MulticastPublisherBlock (for publisher DZ IPs) + let (multicast_publisher_block_ext, _, _) = get_resource_extension_pda( + &client.get_program_id(), + ResourceType::MulticastPublisherBlock, + ); + // Device TunnelIds (scoped to user's device) let (device_tunnel_ids_ext, _, _) = get_resource_extension_pda( &client.get_program_id(), @@ -79,6 +85,7 @@ impl ActivateUserCommand { ); accounts.push(AccountMeta::new(global_resource_ext, false)); + accounts.push(AccountMeta::new(multicast_publisher_block_ext, false)); accounts.push(AccountMeta::new(device_tunnel_ids_ext, false)); // Add all N DzPrefixBlock accounts (devices can have multiple dz_prefixes) @@ -274,6 +281,10 @@ mod tests { // Compute ResourceExtension PDAs let (global_resource_ext, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::UserTunnelBlock); + let (multicast_publisher_block_ext, _, _) = get_resource_extension_pda( + &client.get_program_id(), + ResourceType::MulticastPublisherBlock, + ); let (device_tunnel_ids_ext, _, _) = get_resource_extension_pda( &client.get_program_id(), ResourceType::TunnelIds(device_pk, 0), @@ -320,6 +331,7 @@ mod tests { AccountMeta::new(accesspass_pubkey, false), AccountMeta::new(globalstate_pubkey, false), AccountMeta::new(global_resource_ext, false), + AccountMeta::new(multicast_publisher_block_ext, false), AccountMeta::new(device_tunnel_ids_ext, false), AccountMeta::new(device_dz_prefix_ext, false), ]), diff --git a/smartcontract/sdk/rs/src/commands/user/closeaccount.rs b/smartcontract/sdk/rs/src/commands/user/closeaccount.rs index b2597f74a..6d4e8845f 100644 --- a/smartcontract/sdk/rs/src/commands/user/closeaccount.rs +++ b/smartcontract/sdk/rs/src/commands/user/closeaccount.rs @@ -1,7 +1,7 @@ use crate::{ commands::{ - device::get::GetDeviceCommand, globalstate::get::GetGlobalStateCommand, - user::get::GetUserCommand, + device::get::GetDeviceCommand, globalconfig::get::GetGlobalConfigCommand, + globalstate::get::GetGlobalStateCommand, user::get::GetUserCommand, }, DoubleZeroClient, }; @@ -10,6 +10,7 @@ use doublezero_serviceability::{ processors::user::closeaccount::UserCloseAccountArgs, resource::ResourceType, }; use solana_sdk::{instruction::AccountMeta, pubkey::Pubkey, signature::Signature}; +use std::net::Ipv4Addr; #[derive(Debug, PartialEq, Clone)] pub struct CloseAccountUserCommand { @@ -38,7 +39,9 @@ impl CloseAccountUserCommand { AccountMeta::new(globalstate_pubkey, false), ]; - let dz_prefix_count: u8 = if self.use_onchain_deallocation { + let (dz_prefix_count, multicast_publisher_count): (u8, u8) = if self + .use_onchain_deallocation + { // Fetch device to get dz_prefixes count let (_, device) = GetDeviceCommand { pubkey_or_code: user.device_pk.to_string(), @@ -52,13 +55,36 @@ impl CloseAccountUserCommand { let (global_resource_ext, _, _) = get_resource_extension_pda(&client.get_program_id(), ResourceType::UserTunnelBlock); + accounts.push(AccountMeta::new(global_resource_ext, false)); + + // Determine if user has MulticastPublisherBlock allocation + // Check if dz_ip is allocated and could be from MulticastPublisherBlock + let needs_multicast_publisher_block = + user.dz_ip != user.client_ip && user.dz_ip != Ipv4Addr::UNSPECIFIED && { + // Fetch GlobalConfig to check if dz_ip is in multicast_publisher_block range + let (_, globalconfig) = GetGlobalConfigCommand + .execute(client) + .map_err(|_| eyre::eyre!("GlobalConfig not initialized"))?; + globalconfig.multicast_publisher_block.contains(user.dz_ip) + }; + + let multicast_count = if needs_multicast_publisher_block { + let (multicast_publisher_block_ext, _, _) = get_resource_extension_pda( + &client.get_program_id(), + ResourceType::MulticastPublisherBlock, + ); + accounts.push(AccountMeta::new(multicast_publisher_block_ext, false)); + 1 + } else { + 0 + }; + // Device TunnelIds (scoped to user's device) let (device_tunnel_ids_ext, _, _) = get_resource_extension_pda( &client.get_program_id(), ResourceType::TunnelIds(user.device_pk, 0), ); - accounts.push(AccountMeta::new(global_resource_ext, false)); accounts.push(AccountMeta::new(device_tunnel_ids_ext, false)); // Add all N DzPrefixBlock accounts (devices can have multiple dz_prefixes) @@ -70,13 +96,16 @@ impl CloseAccountUserCommand { accounts.push(AccountMeta::new(device_dz_prefix_ext, false)); } - count as u8 + (count as u8, multicast_count) } else { - 0 + (0, 0) }; client.execute_transaction( - DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { dz_prefix_count }), + DoubleZeroInstruction::CloseAccountUser(UserCloseAccountArgs { + dz_prefix_count, + multicast_publisher_count, + }), accounts, ) } @@ -90,13 +119,14 @@ mod tests { }; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, - pda::{get_globalstate_pda, get_resource_extension_pda}, + pda::{get_globalconfig_pda, get_globalstate_pda, get_resource_extension_pda}, processors::user::closeaccount::UserCloseAccountArgs, resource::ResourceType, state::{ accountdata::AccountData, accounttype::AccountType, device::Device, + globalconfig::GlobalConfig, user::{User, UserCYOA, UserStatus, UserType}, }, }; @@ -142,7 +172,10 @@ mod tests { .expect_execute_transaction() .with( predicate::eq(DoubleZeroInstruction::CloseAccountUser( - UserCloseAccountArgs { dz_prefix_count: 0 }, + UserCloseAccountArgs { + dz_prefix_count: 0, + multicast_publisher_count: 0, + }, )), predicate::eq(vec![ AccountMeta::new(user_pubkey, false), @@ -219,11 +252,33 @@ mod tests { .with(predicate::eq(device_pk)) .returning(move |_| Ok(AccountData::Device(device.clone()))); + // Mock GlobalConfig fetch (to check if dz_ip is in multicast_publisher_block) + let (globalconfig_pubkey, bump_seed) = get_globalconfig_pda(&client.get_program_id()); + let globalconfig = GlobalConfig { + account_type: AccountType::GlobalConfig, + owner: Pubkey::default(), + bump_seed, + local_asn: 0, + remote_asn: 0, + device_tunnel_block: "1.0.0.0/24".parse().unwrap(), + user_tunnel_block: "2.0.0.0/24".parse().unwrap(), + multicastgroup_block: "224.0.0.0/24".parse().unwrap(), + multicast_publisher_block: "147.51.126.0/23".parse().unwrap(), // dz_ip 10.0.0.1 NOT in this range + next_bgp_community: 0, + }; + client + .expect_get() + .with(predicate::eq(globalconfig_pubkey)) + .returning(move |_| Ok(AccountData::GlobalConfig(globalconfig.clone()))); + client .expect_execute_transaction() .with( predicate::eq(DoubleZeroInstruction::CloseAccountUser( - UserCloseAccountArgs { dz_prefix_count: 1 }, // 1 dz_prefix from device.dz_prefixes + UserCloseAccountArgs { + dz_prefix_count: 1, // 1 dz_prefix from device.dz_prefixes + multicast_publisher_count: 0, + }, )), predicate::eq(vec![ AccountMeta::new(user_pubkey, false),