diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b9032f1e..4feb43c97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to this project will be documented in this file. - removes accesspass monitor task (that expires access passes) - Onchain Programs - Refactor user creation to validate all limits (max_users, max_multicast_users, max_unicast_users) before incrementing counters — improves efficiency by avoiding wasted work on validation failures and follows fail-fast best practice + - Serviceability: `UnlinkDeviceInterface` now only allows `Activated` or `Pending` interfaces; when an associated link account is provided for an `Activated` interface, the link must be in `Deleting` status + - SDK: `UnlinkDeviceInterfaceCommand` automatically discovers and passes associated link accounts - E2E / QA Tests - Fix QA unicast test flake caused by RPC 429 rate limiting during concurrent user deletion — treat transient RPC errors as non-fatal in the deletion polling loop diff --git a/activator/src/process/device.rs b/activator/src/process/device.rs index 586a88ce3..7a4f37ff2 100644 --- a/activator/src/process/device.rs +++ b/activator/src/process/device.rs @@ -261,6 +261,12 @@ mod tests { assert!(devices.contains_key(&device_pubkey)); assert_eq!(devices.get(&device_pubkey).unwrap().device, device); + // UnlinkDeviceInterfaceCommand now looks up links to discover associated accounts + client + .expect_gets() + .with(predicate::eq(AccountType::Link)) + .returning(|_| Ok(HashMap::new())); + client .expect_execute_transaction() .times(1) @@ -481,6 +487,12 @@ mod tests { let mut ip_block_allocator = IPBlockAllocator::new("1.1.1.0/24".parse().unwrap()); + // UnlinkDeviceInterfaceCommand now looks up links to discover associated accounts + client + .expect_gets() + .with(predicate::eq(AccountType::Link)) + .returning(|_| Ok(HashMap::new())); + client .expect_execute_transaction() .times(1) diff --git a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/unlink.rs b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/unlink.rs index 141d27405..b77ae649d 100644 --- a/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/unlink.rs +++ b/smartcontract/programs/doublezero-serviceability/src/processors/device/interface/unlink.rs @@ -5,6 +5,7 @@ use crate::{ device::*, globalstate::GlobalState, interface::{InterfaceStatus, InterfaceType}, + link::*, }, }; use borsh::BorshSerialize; @@ -40,6 +41,15 @@ pub fn process_unlink_device_interface( let device_account = next_account_info(accounts_iter)?; let globalstate_account = next_account_info(accounts_iter)?; + + // Optional link account: [device, globalstate, link, payer, system] = 5 accounts + // Without link: [device, globalstate, payer, system] = 4 accounts + let link_account = if accounts.len() > 4 { + Some(next_account_info(accounts_iter)?) + } else { + None + }; + let payer_account = next_account_info(accounts_iter)?; let _system_program = next_account_info(accounts_iter)?; @@ -67,10 +77,34 @@ pub fn process_unlink_device_interface( .find_interface(&value.name) .map_err(|_| DoubleZeroError::InterfaceNotFound)?; - if iface.status == InterfaceStatus::Deleting { + if iface.status != InterfaceStatus::Activated && iface.status != InterfaceStatus::Pending { return Err(DoubleZeroError::InvalidStatus.into()); } + // If interface is Activated and a link account is provided, verify the link + // is in Deleting status (i.e. the link must be deleted before unlinking). + if iface.status == InterfaceStatus::Activated { + if let Some(link_acc) = link_account { + if link_acc.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + let link: Link = Link::try_from(link_acc)?; + + // Validate the link references this device and interface + let is_side_a = + link.side_a_pk == *device_account.key && link.side_a_iface_name == value.name; + let is_side_z = + link.side_z_pk == *device_account.key && link.side_z_iface_name == value.name; + if !is_side_a && !is_side_z { + return Err(ProgramError::InvalidAccountData); + } + + if link.status != LinkStatus::Deleting { + return Err(DoubleZeroError::InvalidStatus.into()); + } + } + } + iface.status = InterfaceStatus::Unlinked; // Only reset ip_net for loopback interfaces (where IPs are auto-allocated from the pool). // Physical interfaces keep their user-provided ip_net. diff --git a/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs new file mode 100644 index 000000000..881944e7e --- /dev/null +++ b/smartcontract/programs/doublezero-serviceability/tests/unlink_device_interface_test.rs @@ -0,0 +1,749 @@ +use doublezero_serviceability::{ + instructions::*, + pda::*, + processors::{ + contributor::create::ContributorCreateArgs, + device::{ + activate::DeviceActivateArgs, + create::*, + interface::{create::*, unlink::*}, + }, + exchange::create::*, + link::{activate::*, create::*, delete::*}, + location::create::*, + }, + resource::ResourceType, + state::{ + accounttype::AccountType, + contributor::ContributorStatus, + device::{DeviceStatus, DeviceType}, + interface::{InterfaceCYOA, InterfaceDIA, InterfaceStatus, LoopbackType, RoutingMode}, + link::*, + }, +}; +use solana_program_test::*; +use solana_sdk::{instruction::AccountMeta, signer::Signer}; + +mod test_helpers; +use test_helpers::*; + +/// Helper to set up a devnet with two devices, each with one interface, a contributor, +/// location, exchange, and a link between the devices. Returns all pubkeys needed. +async fn setup_two_devices_with_link() -> ( + BanksClient, + solana_sdk::signature::Keypair, + solana_sdk::pubkey::Pubkey, // program_id + solana_sdk::pubkey::Pubkey, // globalstate_pubkey + solana_sdk::pubkey::Pubkey, // device_a_pubkey + solana_sdk::pubkey::Pubkey, // device_z_pubkey + solana_sdk::pubkey::Pubkey, // contributor_pubkey + solana_sdk::pubkey::Pubkey, // link_pubkey +) { + let (mut banks_client, payer, program_id, globalstate_pubkey, config_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // 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; + + // 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(config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // 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; + + let contributor = get_account_data(&mut banks_client, contributor_pubkey) + .await + .unwrap() + .get_contributor() + .unwrap(); + assert_eq!(contributor.status, ContributorStatus::Activated); + + // Create device A with interface Ethernet0 + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_a_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "A".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: Default::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + }), + 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; + + let (tunnel_ids_pda_a, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_a_pubkey, 0)); + let (dz_prefix_pda_a, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_a_pubkey, 0)); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(config_pubkey, false), + AccountMeta::new(tunnel_ids_pda_a, false), + AccountMeta::new(dz_prefix_pda_a, false), + ], + &payer, + ) + .await; + + let device_a = get_account_data(&mut banks_client, device_a_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + assert!(matches!( + device_a.status, + DeviceStatus::DeviceProvisioning | DeviceStatus::Activated + )); + + execute_transaction( + &mut 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, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + ip_net: None, + user_tunnel_endpoint: false, + }), + vec![ + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Create device Z with interface Ethernet1 + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_z_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "Z".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: Default::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + }), + 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; + + let (tunnel_ids_pda_z, _, _) = + get_resource_extension_pda(&program_id, ResourceType::TunnelIds(device_z_pubkey, 0)); + let (dz_prefix_pda_z, _, _) = + get_resource_extension_pda(&program_id, ResourceType::DzPrefixBlock(device_z_pubkey, 0)); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(config_pubkey, false), + AccountMeta::new(tunnel_ids_pda_z, false), + AccountMeta::new(dz_prefix_pda_z, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut 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, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + ip_net: None, + user_tunnel_endpoint: false, + }), + vec![ + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Unlink both interfaces (Pending → Unlinked) to prepare for linking + execute_transaction( + &mut 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( + &mut 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 a WAN link between the two devices + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (link_pubkey, _) = get_link_pda(&program_id, globalstate_account.account_index + 1); + + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateLink(LinkCreateArgs { + code: "wan1".to_string(), + link_type: LinkLinkType::WAN, + bandwidth: 10_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), + }), + 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 the link (interfaces become Activated with tunnel IPs) + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::ActivateLink(LinkActivateArgs { + tunnel_id: 500, + tunnel_net: "10.0.0.0/31".parse().unwrap(), + use_onchain_allocation: false, + }), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(device_a_pubkey, false), + AccountMeta::new(device_z_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Verify both interfaces are now Activated + let device_a = get_account_data(&mut banks_client, device_a_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + let iface_a = device_a.find_interface("Ethernet0").unwrap().1; + assert_eq!(iface_a.status, InterfaceStatus::Activated); + + let device_z = get_account_data(&mut banks_client, device_z_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + let iface_z = device_z.find_interface("Ethernet1").unwrap().1; + assert_eq!(iface_z.status, InterfaceStatus::Activated); + + // Verify link is active + let link = get_account_data(&mut banks_client, link_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.account_type, AccountType::Link); + assert_eq!(link.status, LinkStatus::Activated); + + ( + banks_client, + payer, + program_id, + globalstate_pubkey, + device_a_pubkey, + device_z_pubkey, + contributor_pubkey, + link_pubkey, + ) +} + +#[tokio::test] +async fn test_unlink_from_pending() { + let (mut banks_client, payer, program_id, globalstate_pubkey, config_pubkey) = + setup_program_with_globalconfig().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Create location, exchange, contributor, device + 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; + + 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(config_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + 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; + + let globalstate_account = get_globalstate(&mut banks_client, globalstate_pubkey).await; + let (device_pubkey, _) = get_device_pda(&program_id, globalstate_account.account_index + 1); + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::CreateDevice(DeviceCreateArgs { + code: "la".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: Default::default(), + mgmt_vrf: "mgmt".to_string(), + desired_status: None, + }), + 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; + + 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::ActivateDevice(DeviceActivateArgs { resource_count: 2 }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(config_pubkey, false), + AccountMeta::new(tunnel_ids_pda, false), + AccountMeta::new(dz_prefix_pda, false), + ], + &payer, + ) + .await; + + execute_transaction( + &mut 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, + cir: 0, + mtu: 1500, + routing_mode: RoutingMode::Static, + vlan_id: 0, + ip_net: None, + user_tunnel_endpoint: false, + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let device = get_account_data(&mut banks_client, device_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + let iface = device.find_interface("Ethernet0").unwrap().1; + assert_eq!(iface.status, InterfaceStatus::Pending); + + // Unlink from Pending (no link account) should succeed + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet0".to_string(), + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + let device = get_account_data(&mut banks_client, device_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + let iface = device.find_interface("Ethernet0").unwrap().1; + assert_eq!(iface.status, InterfaceStatus::Unlinked); + + // Unlinking an already Unlinked interface should fail with InvalidStatus + let res = try_execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { + name: "Ethernet0".to_string(), + }), + vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + assert!( + res.unwrap_err() + .to_string() + .contains("custom program error: 0x7"), + "Expected InvalidStatus error" + ); +} + +#[tokio::test] +async fn test_unlink_activated_with_active_link_fails() { + let ( + mut banks_client, + payer, + program_id, + globalstate_pubkey, + device_a_pubkey, + _device_z_pubkey, + _contributor_pubkey, + link_pubkey, + ) = setup_two_devices_with_link().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Try to unlink interface on device A while link is still Activated + // Passing the active link account should fail + let res = try_execute_transaction( + &mut 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), + AccountMeta::new(link_pubkey, false), + ], + &payer, + ) + .await; + + assert!( + res.unwrap_err() + .to_string() + .contains("custom program error: 0x7"), + "Expected InvalidStatus error when unlinking with active link" + ); + + // Verify interface is still Activated + let device_a = get_account_data(&mut banks_client, device_a_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + let iface = device_a.find_interface("Ethernet0").unwrap().1; + assert_eq!(iface.status, InterfaceStatus::Activated); +} + +#[tokio::test] +async fn test_unlink_activated_with_deleting_link_succeeds() { + let ( + mut banks_client, + payer, + program_id, + globalstate_pubkey, + device_a_pubkey, + _device_z_pubkey, + contributor_pubkey, + link_pubkey, + ) = setup_two_devices_with_link().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Delete the link first + execute_transaction( + &mut banks_client, + recent_blockhash, + program_id, + DoubleZeroInstruction::DeleteLink(LinkDeleteArgs {}), + vec![ + AccountMeta::new(link_pubkey, false), + AccountMeta::new(contributor_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ], + &payer, + ) + .await; + + // Verify link is now Deleting + let link = get_account_data(&mut banks_client, link_pubkey) + .await + .unwrap() + .get_tunnel() + .unwrap(); + assert_eq!(link.status, LinkStatus::Deleting); + + // Now unlink interface with the Deleting link account — should succeed + execute_transaction( + &mut 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), + AccountMeta::new(link_pubkey, false), + ], + &payer, + ) + .await; + + let device_a = get_account_data(&mut banks_client, device_a_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + let iface = device_a.find_interface("Ethernet0").unwrap().1; + assert_eq!(iface.status, InterfaceStatus::Unlinked); +} + +#[tokio::test] +async fn test_unlink_activated_without_link_account_succeeds() { + let ( + mut banks_client, + payer, + program_id, + globalstate_pubkey, + device_a_pubkey, + _device_z_pubkey, + _contributor_pubkey, + _link_pubkey, + ) = setup_two_devices_with_link().await; + + let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); + + // Unlink without passing link account — should succeed (standalone unlink) + execute_transaction( + &mut 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; + + let device_a = get_account_data(&mut banks_client, device_a_pubkey) + .await + .unwrap() + .get_device() + .unwrap(); + let iface = device_a.find_interface("Ethernet0").unwrap().1; + assert_eq!(iface.status, InterfaceStatus::Unlinked); +} diff --git a/smartcontract/sdk/rs/src/commands/device/interface/unlink.rs b/smartcontract/sdk/rs/src/commands/device/interface/unlink.rs index fe2b51c8b..c44fd3d17 100644 --- a/smartcontract/sdk/rs/src/commands/device/interface/unlink.rs +++ b/smartcontract/sdk/rs/src/commands/device/interface/unlink.rs @@ -1,4 +1,7 @@ -use crate::{commands::globalstate::get::GetGlobalStateCommand, DoubleZeroClient}; +use crate::{ + commands::{globalstate::get::GetGlobalStateCommand, link::list::ListLinkCommand}, + DoubleZeroClient, +}; use doublezero_serviceability::{ instructions::DoubleZeroInstruction, processors::device::interface::DeviceInterfaceUnlinkArgs, }; @@ -16,14 +19,29 @@ impl UnlinkDeviceInterfaceCommand { .execute(client) .map_err(|_err| eyre::eyre!("Globalstate not initialized"))?; + let mut accounts = vec![ + AccountMeta::new(self.pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]; + + // Look up any link that references this device + interface and pass it + // so the onchain program can validate link status for Activated interfaces. + if let Ok(links) = ListLinkCommand.execute(client) { + if let Some((link_pubkey, _)) = links.iter().find(|(_, link)| { + (link.side_a_pk == self.pubkey + && link.side_a_iface_name.eq_ignore_ascii_case(&self.name)) + || (link.side_z_pk == self.pubkey + && link.side_z_iface_name.eq_ignore_ascii_case(&self.name)) + }) { + accounts.push(AccountMeta::new(*link_pubkey, false)); + } + } + client.execute_transaction( DoubleZeroInstruction::UnlinkDeviceInterface(DeviceInterfaceUnlinkArgs { name: self.name.clone(), }), - vec![ - AccountMeta::new(self.pubkey, false), - AccountMeta::new(globalstate_pubkey, false), - ], + accounts, ) } } @@ -32,17 +50,79 @@ impl UnlinkDeviceInterfaceCommand { mod tests { use super::*; use crate::tests::utils::create_test_client; - use doublezero_serviceability::pda::get_globalstate_pda; + use doublezero_serviceability::{ + pda::get_globalstate_pda, + state::{accountdata::AccountData, accounttype::AccountType, link::Link}, + }; use mockall::predicate; + use std::collections::HashMap; #[test] - fn test_commands_device_interface_unlink_command() { + fn test_commands_device_interface_unlink_command_no_link() { let mut client = create_test_client(); let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); let device_pubkey = Pubkey::new_unique(); + // ListLinkCommand returns no links + client + .expect_gets() + .with(predicate::eq(AccountType::Link)) + .returning(|_| Ok(HashMap::new())); + + client + .expect_execute_transaction() + .with( + predicate::eq(DoubleZeroInstruction::UnlinkDeviceInterface( + DeviceInterfaceUnlinkArgs { + name: "Ethernet0".to_string(), + }, + )), + predicate::eq(vec![ + AccountMeta::new(device_pubkey, false), + AccountMeta::new(globalstate_pubkey, false), + ]), + ) + .returning(|_, _| Ok(Signature::new_unique())); + + let res = UnlinkDeviceInterfaceCommand { + pubkey: device_pubkey, + name: "Ethernet0".to_string(), + } + .execute(&client); + assert!(res.is_ok()); + } + + #[test] + fn test_commands_device_interface_unlink_command_with_link() { + let mut client = create_test_client(); + + let (globalstate_pubkey, _globalstate) = get_globalstate_pda(&client.get_program_id()); + + let device_pubkey = Pubkey::new_unique(); + let link_pubkey = Pubkey::new_unique(); + + let link = Link { + account_type: AccountType::Link, + side_a_pk: device_pubkey, + side_a_iface_name: "Ethernet0".to_string(), + side_z_pk: Pubkey::new_unique(), + side_z_iface_name: "Ethernet1".to_string(), + ..Default::default() + }; + + let link_clone = link.clone(); + client + .expect_gets() + .with(predicate::eq(AccountType::Link)) + .returning(move |_| { + Ok(HashMap::from([( + link_pubkey, + AccountData::Link(link_clone.clone()), + )])) + }); + client .expect_execute_transaction() .with( @@ -54,6 +134,7 @@ mod tests { predicate::eq(vec![ AccountMeta::new(device_pubkey, false), AccountMeta::new(globalstate_pubkey, false), + AccountMeta::new(link_pubkey, false), ]), ) .returning(|_, _| Ok(Signature::new_unique()));